From 31495498353f51315a36903bcf355ce369d72076 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 23 Oct 2026 00:10:57 -0500 Subject: [PATCH] =?UTF-8?q?Reconstruct=20batch=20087/124:=202026-10-02=20?= =?UTF-8?q?=E2=86=92=202026-10-06=20(agenthost,=20chat,=20sessions,=20mcp)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashed reconstruction of 50 upstream commits (32a12c70b96..1f2e1f2ee27). 576 files changed, 47676 insertions(+), 6404 deletions(-) agenthost: - agentHost: let chats switch workspace or move to a worktree via tools (#339778) - agentHost: Extend bounded telemetry context (#339800) - agentHost: forward tool-result images to BYOK models - agentHost: preserve tool-result part order across the BYOK bridge - agentHost: carry ordered tool-output content parts in the BYOK bridge - agentHost: forward tool-result images to BYOK models (#339830) - Use proper ahp-session: scheme URIs for new AH sessions (#339492) - agentHost: update Copilot SDK to 1.0.17-preview.5 (#339835) - Classify HydraFusion as an agent host selection mode (#339915) - agentHost: enable BYOK models by default - agentHost: mirror default BYOK enablement - agentHost: expand SDK regression and managed-settings E2E coverage (#339864) - agentHost: native Mission Control environments (#339513) - agentHost: isolate BYOK sync test registration - agentHost: enable BYOK models by default (#339931) - Try to fix "archiving a never-restored session survives a host restart" (#339676) - Count HydraFusion phase model calls in turnCompleted (#339921) - Fix MCP connector list reconciliation after authentication (#339945) - Revert deferred title generation default (#339908) chat: - chat: Open model picker on touch and pen taps (#339777) - chat: Show only the selected harness's Copilot models in Manage Models - chat: Scope Manage Models group actions and statuses to the harness - chat: restore Codex continuation with safe session routing (#339790) - chat: Show only the selected harness's Copilot models in Manage Models (#339840) - chat: respect managed Codex enablement in the editor (#339903) - Implement migration actions in customization detail headers (#339935) - chat: render native image generation (#337624) - Avoid duplicated question text in cloud sandbox chat (#339624) - Stabilize customization bundle serialization test (#339811) sessions: - sessions: Add configurable bottom panel alignment - sessions: Harden panel layout restoration - sessions: Forward committed panel maximization state - sessions: Suppress archive nudges after continuing a conversation (#339787) - sessions: Enable New Session V2 tour in Insiders (#339820) - sessions: remove Automations new badge (#339855) - sessions: Remove session title from command center (#339856) mcp: - mcp: show where built-in MCP servers come from - mcp: take MCP settings links from the host and refresh open details - mcp: show where built-in MCP servers come from (#339427) github: - github: migrate Dev Container sample reads (#339533) - github: report request telemetry every ten minutes (#339907) agents window: - Agents window: make bottom panel alignment configurable (#339736) test: - test: Stabilize flaky component fixtures (#339789) extensions: - extensions: keep update checks on polling cadence (#339365) base: - Fix send button overflow in chat input toolbar for narrow widths (#339847) extensions/copilot: - Fix internal-org and staff targeting in the new TAS endpoint (#339929) fix: - fix: memory leak in extHostTimeline (#334098) Co-Authored-By: Claude Opus 5.5 --- ...gent-host-interoperability.instructions.md | 6 + .github/skills/component-fixtures/SKILL.md | 2 + .../lib/stylelint/vscode-known-variables.json | 4 + eslint.config.js | 35 + .../test/node/assignmentFilters.spec.ts | 35 + .../microsoftExperimentationService.ts | 2 +- package-lock.json | 88 +- package.json | 5 +- remote/package-lock.json | 88 +- remote/package.json | 5 +- src/vs/base/browser/ui/grid/gridview.ts | 40 +- src/vs/base/browser/ui/list/listView.ts | 22 +- .../browser/ui/pixelSpinner/pixelSpinner.css | 3 +- src/vs/base/browser/ui/toolbar/toolbar.ts | 37 +- src/vs/base/test/browser/ui/grid/grid.test.ts | 126 +- .../test/browser/ui/list/listView.test.ts | 106 + .../test/browser/ui/toolbar/toolbar.test.ts | 41 + .../actionWidget/browser/actionList.ts | 14 + .../actionWidget/browser/actionWidget.ts | 5 + .../test/browser/actionList.test.ts | 10 + src/vs/platform/agentHost/AGENTS.md | 25 +- src/vs/platform/agentHost/OTEL.md | 4 + .../browser/agentHostConnectionsService.ts | 101 +- .../browser/agentHostProtocolClient.ts | 234 +- .../browser/webPubSubRelayTransport.ts | 109 +- src/vs/platform/agentHost/common/agent.ts | 34 +- .../agentHost/common/agentHostByokLm.ts | 13 +- .../agentHost/common/agentHostChatIdentity.ts | 12 + .../common/agentHostConnectionsService.ts | 14 + .../common/agentHostExtensionProtocol.ts | 5 + .../agentHost/common/agentHostGitService.ts | 4 +- .../common/agentHostGitStateService.ts | 3 + .../common/agentHostOperatingSystem.ts | 11 + .../agentHost/common/agentHostSchema.ts | 20 +- .../common/agentHostSessionIdentity.ts | 16 + .../agentHostStarter.config.contribution.ts | 5 +- .../agentHost/common/agentHostTelemetry.ts | 10 + .../agentHost/common/agentServerTools.ts | 6 +- .../platform/agentHost/common/agentService.ts | 38 +- .../agentHost/common/cloudSandboxAgentHost.ts | 22 +- .../agentHost/common/codexAccountPlan.ts | 35 + .../agentHost/common/codexModelSelection.ts | 37 + .../agentHost/common/fileEditAttribution.ts | 1 + .../common/meta/agentHostSessionUrisMeta.ts | 6 + .../common/meta/agentImageGenerationMeta.ts | 6 + .../agentHost/common/meta/codexAccount.ts | 2 +- .../common/meta/codexSessionModel.ts | 55 + .../common/meta/mcpCustomizationMeta.ts | 2 +- .../common/meta/relayConnectionMeta.ts | 15 + .../meta/vscode/agentHostSessionUrisMeta.ts | 34 + .../meta/vscode/agentImageGenerationMeta.ts | 38 + .../common/meta/vscode/agentToolCallMeta.ts | 5 + .../common/meta/vscode/codexAccount.ts | 4 + .../meta/vscode/mcpCustomizationMeta.ts | 51 + .../agentHost/common/openSessionLink.ts | 31 +- .../agentHost/common/pendingEditContentUri.ts | 34 + .../common/remoteAgentHostService.ts | 2 + .../agentHost/common/serverToolNames.ts | 1 + .../common/state/agentSubscription.ts | 8 + .../agentHost/common/state/sessionState.ts | 2 +- .../common/state/sessionTransport.ts | 9 + .../common/titleGenerationConfiguration.ts | 31 + .../agentHost/common/webPubSub/chunking.ts | 8 +- .../agentHost/common/webPubSub/framing.ts | 47 +- .../electron-browser/localAgentHostService.ts | 46 +- .../node/agentHostAutomationService.ts | 2 +- .../agentHost/node/agentHostBootstrap.ts | 4 + .../node/agentHostBranchChangesetScope.ts | 7 +- .../node/agentHostCatalogListReader.ts | 8 +- .../node/agentHostCatalogProjection.ts | 8 + .../node/agentHostCatalogSourceResolver.ts | 11 +- .../node/agentHostChangesetCoordinator.ts | 2 +- .../node/agentHostChangesetService.ts | 9 +- .../node/agentHostClientConnectionService.ts | 7 + .../agentHost/node/agentHostContributions.ts | 1 + .../node/agentHostGitStateService.ts | 50 +- .../platform/agentHost/node/agentHostMain.ts | 25 +- .../node/agentHostManagementService.ts | 27 +- .../agentHost/node/agentHostPeerChatStore.ts | 3 +- .../node/agentHostProviderService.ts | 2 +- .../node/agentHostSessionTitleController.ts | 4 +- .../node/agentHostSessionTitleSignal.ts | 4 +- .../agentHost/node/agentHostShutdown.ts | 16 +- .../agentHost/node/agentHostStateManager.ts | 15 +- .../agentHost/node/agentHostStorageService.ts | 15 +- .../node/agentHostTelemetryReporter.ts | 12 +- .../node/agentHostTurnTelemetryContext.ts | 19 +- .../agentHost/node/agentHostTurnTracker.ts | 61 +- .../platform/agentHost/node/agentService.ts | 378 +- .../agentHost/node/agentServiceComposition.ts | 19 +- .../agentHost/node/agentSideEffects.ts | 17 +- .../sessionWorkspaceConversionContribution.ts | 10 +- .../sessionWorkspaceConversionService.ts | 490 +- .../node/claude/claudeFileEditObserver.ts | 2 + .../node/codex/codexAccountTelemetry.ts | 55 +- .../agentHost/node/codex/codexAgent.ts | 111 +- .../node/codex/codexAppServerClient.ts | 10 +- .../node/codex/codexMapAppServerEvents.ts | 23 +- .../codex/codexProviderSwitchTelemetry.ts | 10 +- .../agentHost/node/codex/codexReplayMapper.ts | 3 +- .../node/codex/codexSessionMetadataStore.ts | 8 +- .../agentHost/node/codexCompactCommand.ts | 7 +- .../node/copilot/byokResponsesTranslation.ts | 35 +- .../agentHost/node/copilot/copilotAgent.ts | 361 +- .../node/copilot/copilotAgentSession.ts | 121 +- .../node/copilot/copilotSessionLauncher.ts | 28 +- .../copilot/copilotSessionMetadataEvents.ts | 144 + .../copilotSlashCommandCompletionProvider.ts | 3 +- .../node/copilot/copilotTodoStoreTelemetry.ts | 4 +- .../node/copilot/copilotToolDisplay.ts | 21 +- .../node/copilot/mapSessionEvents.ts | 19 +- .../node/copilot/pendingEditContentStore.ts | 25 +- .../node/devContainerAgentHostService.ts | 25 +- .../agentHost/node/devContainerSamples.ts | 11 +- .../node/missionControl/MISSION_CONTROL.md | 78 + .../MISSION_CONTROL_PRODUCTION_GAPS.md | 50 + .../MISSION_CONTROL_SECURITY_REQUIREMENTS.md | 88 + .../missionControlAuthentication.ts | 270 + .../missionControl/missionControlControl.ts | 141 + .../missionControlEnvironment.ts | 649 ++ .../node/missionControl/missionControlHost.ts | 152 + .../missionControlProtocolServer.ts | 506 ++ .../missionControlSdkEventSource.ts | 381 ++ .../missionControlSessionMirror.ts | 751 +++ .../agentHost/node/protocolServerHandler.ts | 636 +- .../shared/agentEditAttributionService.ts | 4 +- .../node/shared/agentServerToolHost.ts | 14 +- .../agentHost/node/shared/editArcReporter.ts | 3 +- .../node/shared/editSurvivalReporter.ts | 3 +- .../agentHost/node/shared/fileEditTracker.ts | 4 + .../node/shared/mcpCustomizationController.ts | 65 +- .../node/shared/persistSessionMetadata.ts | 13 + .../agentHost/node/shared/serverToolGroups.ts | 4 +- .../node/shared/sessionIsolationTools.ts | 47 + .../node/shared/sessionServerTools.ts | 39 +- .../node/shared/worktreeIsolation.ts | 152 +- .../agentHostConnectionsService.test.ts | 123 + .../browser/webPubSubRelayTransport.test.ts | 164 +- .../common/agentHostConfigurationSync.test.ts | 22 + .../common/agentHostOperatingSystem.test.ts | 33 +- .../test/common/agentHostSchema.test.ts | 24 +- .../common/agentHostSessionIdentity.test.ts | 69 + .../common/agentImageGenerationMeta.test.ts | 62 + .../test/common/agentMetaReaders.test.ts | 33 +- .../test/common/agentService.test.ts | 79 +- .../test/common/codexAccount.test.ts | 10 + .../test/common/codexSessionModel.test.ts | 45 + .../common/meta/relayConnectionMeta.test.ts | 19 + .../test/common/openSessionLink.test.ts | 44 +- .../test/common/pendingEditContentUri.test.ts | 35 + .../test/common/sessionTestHelpers.ts | 1 + .../titleGenerationConfiguration.test.ts | 33 + .../test/common/webPubSub/framing.test.ts | 33 + .../agentHostProtocolClient.test.ts | 419 +- .../remoteAgentHostService.test.ts | 25 + .../node/agentHostAutomationService.test.ts | 2 + .../node/agentHostCatalogListReader.test.ts | 4 + .../agentHostCatalogSourceResolver.test.ts | 36 +- .../agentHostChangesetCoordinator.test.ts | 1 + ...agentHostChangesetOperationService.test.ts | 1 + .../node/agentHostChangesetService.test.ts | 1 + .../node/agentHostChangesetStrategy.test.ts | 115 +- .../test/node/agentHostContributions.test.ts | 1 + .../test/node/agentHostGitService.test.ts | 4 +- .../node/agentHostGitStateService.test.ts | 114 + .../agentHostMergeOperationProvider.test.ts | 1 + ...ntHostPullRequestOperationProvider.test.ts | 2 + .../agentHostReviewService.integrationTest.ts | 1 + .../test/node/agentHostReviewService.test.ts | 2 + .../agentHostSessionTitleController.test.ts | 9 + .../test/node/agentHostShutdown.test.ts | 31 + .../test/node/agentHostStateManager.test.ts | 21 +- .../test/node/agentHostStorageService.test.ts | 65 +- .../node/agentHostTelemetryReporter.test.ts | 4 + .../node/agentHostToolCallTelemetry.test.ts | 9 +- .../node/agentHostTurnHangTelemetry.test.ts | 9 +- .../test/node/agentHostTurnTelemetry.test.ts | 76 +- .../agentHost/test/node/agentService.test.ts | 1566 ++++- .../test/node/agentServiceTestUtils.ts | 4 +- .../test/node/agentSideEffects.test.ts | 102 +- .../node/byokResponsesTranslation.test.ts | 30 + .../test/node/chatContributions.test.ts | 73 +- .../agentHost/test/node/claudeAgent.test.ts | 19 +- .../node/codex/codexAccountTelemetry.test.ts | 40 +- .../node/codex/codexChatDiscovery.test.ts | 52 + .../test/node/codex/codexCreateChat.test.ts | 264 +- .../codex/codexMapAppServerEvents.test.ts | 42 +- .../test/node/codex/codexModelRefresh.test.ts | 8 +- .../node/codex/codexPrewarmEviction.test.ts | 12 +- .../codexProviderSwitchTelemetry.test.ts | 10 +- .../test/node/codex/codexReplayMapper.test.ts | 75 +- .../codex/codexSessionMetadataStore.test.ts | 30 + .../agentHost/test/node/copilotAgent.test.ts | 824 ++- .../test/node/copilotAgentSession.test.ts | 437 +- .../test/node/copilotSessionLauncher.test.ts | 210 +- .../node/copilotSessionMetadataEvents.test.ts | 111 + .../test/node/copilotToolDisplay.test.ts | 30 +- .../node/devContainerAgentHostService.test.ts | 2 + .../test/node/devContainerSamples.test.ts | 41 +- .../agentHost/test/node/e2e/KNOWN_ISSUES.md | 60 + .../agentHost/test/node/e2e/README.md | 23 +- ...ute-its-first-tool-turn-after-restart.yaml | 96 + ...te-restored-peer-backings-to-sessions.yaml | 37 + ...-retain-independent-provider-contexts.yaml | 56 + ...ns-ordered-user-and-assistant-context.yaml | 42 + ...s-paired-with-its-result-after-resume.yaml | 116 + ...separate-completed-turns-after-resume.yaml | 42 + ...quent-edits-stay-on-the-restored-peer.yaml | 249 + ...in-their-turn-ownership-after-restart.yaml | 307 + ...uccessful-tool-and-resource-responses.yaml | 12 + ...ents-with-distinct-uris-and-encodings.yaml | 12 + ...ge-and-embedded-resource-tool-content.yaml | 12 + ...l-results-and-private-result-metadata.yaml | 12 + ...e-after-a-transient-real-server-error.yaml | 12 + ...-tool-result-without-fabricating-text.yaml | 12 + ...e-read-on-the-same-advertised-channel.yaml | 12 + ...rn-fork-excludes-later-source-context.yaml | 38 + ...-own-branch-after-the-source-advances.yaml | 72 + ...eted-tools-but-excludes-the-next-turn.yaml | 171 + ...d-without-changing-its-source-history.yaml | 148 + ...ute-its-first-tool-turn-after-restart.yaml | 82 + ...te-restored-peer-backings-to-sessions.yaml | 34 + ...-retain-independent-provider-contexts.yaml | 56 + ...ns-ordered-user-and-assistant-context.yaml | 44 + ...s-paired-with-its-result-after-resume.yaml | 105 + ...separate-completed-turns-after-resume.yaml | 42 + ...quent-edits-stay-on-the-restored-peer.yaml | 198 + ...in-their-turn-ownership-after-restart.yaml | 262 + ...h-materialized-default-and-peer-chats.yaml | 217 + ...st-a-removed-managed-ask-contribution.yaml | 110 + ...-the-current-managed-ask-contribution.yaml | 110 + ...pture-instead-of-the-persisted-policy.yaml | 25 + ...denial-overrides-local-content-opt-in.yaml | 12 + ...-an-inherited-trace-specific-endpoint.yaml | 12 + ...-inherited-identity-from-native-spans.yaml | 12 + ...and-trace-specific-protobuf-protocols.yaml | 12 + ...apture-overrides-local-content-opt-in.yaml | 12 + ...n-be-reenabled-after-a-denied-session.yaml | 21 + ...denial-overrides-local-content-opt-in.yaml | 12 + ...apture-overrides-local-content-opt-in.yaml | 12 + ...roval-remains-one-time-under-assisted.yaml | 104 + ...al-remains-one-time-under-autoapprove.yaml | 101 + ...locks-the-native-tool-under-allow-all.yaml | 37 + ...tain-unrelated-environment-attributes.yaml | 12 + ...denial-overrides-local-content-opt-in.yaml | 12 + ...apture-overrides-local-content-opt-in.yaml | 12 + ...inherited-service-resource-attributes.yaml | 12 + ...des-an-environment-selected-collector.yaml | 12 + ...roval-remains-one-time-under-assisted.yaml | 107 + ...al-remains-one-time-under-autoapprove.yaml | 110 + ...locks-the-native-tool-under-allow-all.yaml | 40 + ...nial-prevents-mutation-under-assisted.yaml | 39 + ...l-prevents-mutation-under-autoapprove.yaml | 39 + ...uccessful-tool-and-resource-responses.yaml | 12 + ...ents-with-distinct-uris-and-encodings.yaml | 12 + ...ge-and-embedded-resource-tool-content.yaml | 12 + ...l-results-and-private-result-metadata.yaml | 12 + ...n-existing-tool-without-a-name-change.yaml | 12 + ...er-a-real-server-adds-a-callable-tool.yaml | 12 + ...catalog-while-retaining-healthy-calls.yaml | 12 + ...e-after-a-transient-real-server-error.yaml | 12 + ...-tool-result-without-fabricating-text.yaml | 12 + ...e-read-on-the-same-advertised-channel.yaml | 12 + ...ed-content-capture-without-restarting.yaml | 21 + ...fferent-collectors-without-restarting.yaml | 21 + ...rn-fork-excludes-later-source-context.yaml | 38 + ...-own-branch-after-the-source-advances.yaml | 72 + ...eted-tools-but-excludes-the-next-turn.yaml | 118 + ...s-the-current-client-tool-definitions.yaml | 46 + ...d-without-changing-its-source-history.yaml | 101 + ...ute-its-first-tool-turn-after-restart.yaml | 59 + ...te-restored-peer-backings-to-sessions.yaml | 34 + ...cement-reaches-the-next-model-request.yaml | 120 + ...s-permit-a-subsequent-successful-call.yaml | 123 + ...s-permit-a-subsequent-successful-call.yaml | 123 + ...s-permit-a-subsequent-successful-call.yaml | 126 + ...ement-reaches-a-live-provider-session.yaml | 127 + ...erialization-execute-on-the-next-turn.yaml | 53 + ...-retain-independent-provider-contexts.yaml | 56 + ...-tool-execution-to-the-current-client.yaml | 120 + ...ns-ordered-user-and-assistant-context.yaml | 42 + ...s-paired-with-its-result-after-resume.yaml | 67 + ...separate-completed-turns-after-resume.yaml | 42 + ...ement-reaches-both-materialized-chats.yaml | 228 + ...quent-edits-stay-on-the-restored-peer.yaml | 122 + ...ool-leaves-its-replacement-executable.yaml | 123 + ...in-their-turn-ownership-after-restart.yaml | 160 + ...and-peer-chats-before-their-next-turn.yaml | 214 + ...he-previous-exporter-in-a-new-session.yaml | 21 + .../node/e2e/coverage/protocol-surface.json | 19 +- .../test/node/e2e/coverage/summary.json | 5886 +++++++++++------ ...after_start_and_completes.traffic.ahp.yaml | 3 + ...Copilot_prompts_claude-haiku-4_5.prompt.md | 53 +- ..._Copilot_prompts_claude-opus-4_5.prompt.md | 53 +- ..._Copilot_prompts_claude-opus-4_6.prompt.md | 53 +- ..._Copilot_prompts_claude-opus-4_7.prompt.md | 53 +- ...___Copilot_prompts_claude-opus-5.prompt.md | 53 +- ..._Copilot_prompts_claude-opus-5_5.prompt.md | 53 +- ...opilot_prompts_claude-sonnet-4_5.prompt.md | 53 +- ...opilot_prompts_claude-sonnet-4_6.prompt.md | 53 +- ..._Copilot_prompts_claude-sonnet-5.prompt.md | 53 +- ...Copilot_prompts_gemini-2_0-flash.prompt.md | 59 +- ...2E___Copilot_prompts_gpt-5-codex.prompt.md | 59 +- ...E2E___Copilot_prompts_gpt-5-mini.prompt.md | 59 +- ...Host_E2E___Copilot_prompts_gpt-5.prompt.md | 59 +- ...pilot_prompts_gpt-5_1-codex-mini.prompt.md | 59 +- ...___Copilot_prompts_gpt-5_1-codex.prompt.md | 59 +- ...st_E2E___Copilot_prompts_gpt-5_1.prompt.md | 59 +- ...E___Copilot_prompts_gpt-5_6-luna.prompt.md | 59 +- ...2E___Copilot_prompts_gpt-5_6-sol.prompt.md | 59 +- ...___Copilot_prompts_gpt-5_6-terra.prompt.md | 59 +- ...gedSettingsAgentHostE2E.integrationTest.ts | 217 +- ...copilotOtelAgentHostE2E.integrationTest.ts | 266 +- .../node/e2e/suites/agentHostE2ESuites.ts | 2 + .../test/node/e2e/suites/changesetSuite.ts | 186 +- .../e2e/suites/copilotRuntimeToolsSuite.ts | 233 +- .../node/e2e/suites/mcpSideChannelSuite.ts | 315 +- .../test/node/e2e/suites/serverToolsSuite.ts | 95 +- .../e2e/suites/sessionPersistenceSuite.ts | 397 +- .../e2e/suites/terminalResilienceSuite.ts | 164 + .../e2e/suites/workspaceConversionSuite.ts | 4 +- .../test/node/fileEditTracker.test.ts | 38 +- .../test/node/mapSessionEvents.test.ts | 183 +- .../node/missionControlAuthentication.test.ts | 197 + .../test/node/missionControlHost.test.ts | 116 + .../node/missionControlProtocolServer.test.ts | 1319 ++++ .../node/missionControlSdkEventSource.test.ts | 221 + .../node/missionControlSessionMirror.test.ts | 624 ++ .../test/node/protocolServerHandler.test.ts | 961 ++- .../test/node/serverToolGroups.test.ts | 8 +- .../test/node/sessionIsolationTools.test.ts | 103 + .../test/node/sessionServerTools.test.ts | 51 +- .../node/sessionWorkspaceConversion.test.ts | 1380 +++- .../test/node/shared/editArcReporter.test.ts | 14 + .../node/shared/editSurvivalReporter.test.ts | 14 + .../shared/mcpCustomizationController.test.ts | 56 +- .../node/shared/worktreeIsolation.test.ts | 333 +- src/vs/platform/github/README.md | 22 +- .../platform/github/common/anonymousClient.ts | 133 + src/vs/platform/github/common/cancellation.ts | 26 + .../platform/github/common/githubEndpoints.ts | 18 + src/vs/platform/github/common/githubIpc.ts | 10 +- .../github/common/githubRequestTelemetry.ts | 2 +- .../platform/github/common/githubResponse.ts | 9 + .../platform/github/common/githubService.ts | 76 +- .../platform/github/common/githubTransport.ts | 46 +- src/vs/platform/github/common/githubTypes.ts | 2 + src/vs/platform/github/common/githubUrls.ts | 5 + .../test/common/anonymousClient.test.ts | 980 +++ .../github/test/common/cancellation.test.ts | 80 + .../test/common/githubAnonymousClient.test.ts | 389 -- .../test/common/githubEndpoints.test.ts | 1 + .../github/test/common/githubIpc.test.ts | 1 + .../github/test/common/githubResponse.test.ts | 22 +- .../github/test/common/githubUrls.test.ts | 28 +- .../notification/common/notification.ts | 2 + src/vs/platform/storage/common/storage.ts | 19 +- src/vs/sessions/LAYOUT.md | 2 + src/vs/sessions/browser/desktopWorkbench.ts | 34 +- src/vs/sessions/browser/layoutActions.ts | 32 +- src/vs/sessions/browser/media/workbench.css | 34 +- src/vs/sessions/browser/parts/panelPart.ts | 25 +- src/vs/sessions/browser/workbench.ts | 208 +- .../common/agentHostSessionsProvider.ts | 17 + .../contrib/chat/browser/modelPicker.ts | 4 + .../openSessionLinkOpener.contribution.ts | 12 +- .../chat/browser/sessionArchiveNudge.ts | 85 +- .../browser/sessionsChatAccessibilityHelp.ts | 1 + .../electron-browser/chat.contribution.ts | 10 +- .../chat/test/browser/newChatInput.fixture.ts | 4 + .../browser/openSessionLinkOpener.test.ts | 20 +- .../test/browser/sessionArchiveNudge.test.ts | 242 +- .../sessionsChatAccessibilityHelp.test.ts | 2 + .../newSessionViewV2TourContribution.ts | 9 +- .../browser/newSessionViewV2TourVariation.ts | 11 +- .../browser/tours/newSessionViewV2Tour.ts | 8 +- .../test/browser/newSessionViewV2Tour.test.ts | 28 +- .../agentHost/AGENT_HOST_SESSIONS_PROVIDER.md | 46 +- .../browser/agentHostAutomationStore.ts | 15 +- .../browser/baseAgentHostSessionsProvider.ts | 308 +- .../browser/codexContinuation.contribution.ts | 73 + .../browser/localAgentHostSessionsProvider.ts | 15 +- .../browser/agentHostAutomationStore.test.ts | 36 + .../browser/agentHostModePicker.fixture.ts | 9 +- .../localAgentHostSessionsProvider.test.ts | 631 +- .../cloudSandboxAgentHostContribution.ts | 12 +- .../browser/cloudSandboxSessionsProvider.ts | 96 +- .../entryDrivenProviderContribution.ts | 4 +- .../missionControlAgentHostContribution.ts | 72 + .../browser/remoteAgentHost.contribution.ts | 1 + .../remoteAgentHostSessionsProvider.ts | 72 +- .../cloudSandboxAgentHostContribution.test.ts | 20 +- .../browser/connectionDiagnostics.fixture.ts | 37 +- .../remoteAgentHost.contribution.test.ts | 22 + .../remoteAgentHostSessionsProvider.test.ts | 405 +- .../browser/remoteSessionSource.ts | 14 +- .../test/browser/remoteSessionTools.test.ts | 22 + .../sessions/browser/automationsNewBadge.ts | 199 - .../sessions/browser/media/sessionsList.css | 38 - .../browser/media/sessionsListNotice.css | 42 + .../browser/media/sessionsTitleBarWidget.css | 8 - .../sessions/browser/sessions.contribution.ts | 14 - .../browser/sessionsTitleBarWidget.ts | 63 +- .../sessions/browser/views/sessionsList.ts | 54 +- .../browser/views/sessionsListNotice.ts | 78 + .../sessions/browser/views/sessionsView.ts | 23 + .../browser/views/sessionsViewActions.ts | 20 - .../test/browser/automationsNewBadge.test.ts | 361 - .../test/browser/sessionsList.fixture.ts | 57 +- .../test/browser/sessionsList.test.ts | 212 +- .../test/browser/sessionsListFixtureUtils.ts | 36 +- .../test/browser/sessionsListNotice.test.ts | 48 + .../browser/sessionsTitleBarWidget.test.ts | 71 +- src/vs/sessions/sessions.desktop.main.ts | 1 + .../test/browser/agentsPartCard.test.ts | 61 + .../test/browser/layoutActions.test.ts | 59 + .../sessions/test/browser/workbench.test.ts | 772 ++- .../workbench/api/common/extHostTimeline.ts | 12 +- .../api/test/browser/extHostTimeline.test.ts | 113 + src/vs/workbench/common/notifications.ts | 2 + .../browser/actions/chatAccessibilityHelp.ts | 11 +- .../agentHost/agentHostByokLmHandler.ts | 6 +- .../agentHost/agentHostChatInputPicker.ts | 6 +- .../agentHostCustomizationService.ts | 20 +- .../agentHost/agentHostGenericConfigChips.ts | 4 +- .../agentHost/agentHostModeSynchronizer.ts | 6 +- .../agentHost/agentHostSessionHandler.ts | 81 +- .../agentHostSessionListController.ts | 38 +- .../agentHost/agentHostSessionListStore.ts | 23 +- .../agentHost/agentHostSessionUri.ts | 24 +- ...ntHostUntitledProvisionalSessionService.ts | 27 +- .../agentSessionSettings.contribution.ts | 3 +- .../agentHost/applyAgentHostSessionConfig.ts | 18 +- .../agentHost/applyAgentHostSubmitConfig.ts | 2 + .../codexContinuation.contribution.ts | 99 + .../agentHost/codexContinuationGuide.ts | 298 + .../agentHost/codexContinuationPresenter.ts | 127 + .../copilotConfigSlashSubmitHandler.ts | 3 + .../openSessionLinkOpener.contribution.ts | 79 +- .../agentHost/stateToProgressAdapter.ts | 197 +- .../agentSessions/agentSessionsControl.ts | 57 +- .../agentSessions/agentSessionsViewer.ts | 29 +- .../aiCustomizationDiscoveryPage.ts | 3 + .../aiCustomizationManagementEditor.ts | 198 +- .../customizationMigrationAccessibility.ts | 2 +- .../embeddedMcpServerDetail.ts | 160 +- .../browser/aiCustomization/mcpListWidget.ts | 163 +- .../media/aiCustomizationDiscovery.css | 18 +- .../media/aiCustomizationManagement.css | 27 + .../attachments/chatAttachmentWidgets.ts | 133 +- .../attachments/chatImageGlyphSurface.ts | 394 ++ .../browser/attachments/chatImageReveal.css | 97 + .../browser/attachments/chatImageReveal.ts | 111 + .../browser/attachments/chatImageTextures.ts | 464 ++ .../chat/browser/chat.shared.contribution.ts | 21 + .../chatManagement.contribution.ts | 14 +- .../chatManagement/chatManagementEditor.ts | 5 + .../chatManagement/chatModelsViewModel.ts | 61 +- .../chatManagement/chatModelsWidget.ts | 8 + .../contrib/chat/browser/chatSlashCommands.ts | 6 +- .../cloudSandboxAgentHostService.ts | 69 +- .../remoteAgentHost/cloudSandboxApiService.ts | 46 + .../cloudSandboxConnectionCustomization.ts | 15 +- .../cloudSandboxCredentialRefresh.ts | 55 +- .../cloudSandboxReadOnlySessionHandler.ts | 4 +- .../cloudSandboxSessionContribution.ts | 36 +- .../cloudSandboxSessionListController.ts | 1 + .../remoteAgentHost/cloudSandboxTelemetry.ts | 2 +- .../editorCloudSandboxContribution.ts | 4 +- .../missionControlCredentialSealing.ts | 46 + .../missionControlEnvironmentActions.ts | 134 + .../remoteAgentHost.contribution.ts | 182 + .../voiceClient/voiceSessionController.ts | 6 +- .../chatAttachmentsContentPart.ts | 19 +- .../chatProgressContentPart.ts | 66 +- .../chatResourceGroupWidget.ts | 54 +- .../chatToolInputOutputContentPart.ts | 31 +- .../media/chatImageGenerationProgressPart.css | 8 + .../media/chatThinkingContent.css | 8 + .../chatGeneratedImageResultSubPart.ts | 83 +- .../chatImageGenerationProgressPart.ts | 127 + .../chatInputOutputMarkdownProgressPart.ts | 16 +- .../chatSimpleToolProgressPart.ts | 1 + .../chatToolInvocationPart.ts | 183 +- .../chatToolPartUtilities.ts | 42 + .../chat/browser/widget/chatListRenderer.ts | 71 +- .../chat/browser/widget/chatListWidget.ts | 4 + .../browser/widget/input/chatInputPart.ts | 12 + .../input/editor/agentHostInputCompletions.ts | 2 + .../modelPicker/modelPickerActionItem.ts | 29 +- .../input/modelPicker/modelPickerItems.ts | 4 +- .../modelPicker/modelPickerTabbedWidget.ts | 13 +- .../input/modelPicker/modelPickerWidget.ts | 53 +- .../chat/browser/widget/media/chat.css | 45 +- .../widgetHosts/viewPane/chatViewPane.ts | 15 +- .../chat/common/chatService/chatService.ts | 5 +- .../chatProgressTypes/chatToolInvocation.ts | 11 +- .../chat/common/model/chatViewModel.ts | 2 + .../chatAccessibilityHelp.test.ts | 38 + ...openSessionLinkOpener.contribution.test.ts | 116 +- .../agentHostByokLmHandler.test.ts | 32 + .../agentHostChatContribution.test.ts | 176 +- .../agentHostChatInputPicker.test.ts | 24 +- .../agentHostClientTools.test.ts | 25 +- .../agentHostCustomizationService.test.ts | 51 +- .../agentHostGenericConfigChips.test.ts | 54 + .../agentHostModeSynchronizer.test.ts | 6 +- .../agentSessions/agentHostSessionUri.test.ts | 64 + ...tUntitledProvisionalSessionService.test.ts | 147 +- ...tSessionSettingsFileSystemProvider.test.ts | 17 +- .../agentSessionsControl.test.ts | 65 + .../codexContinuationGuide.test.ts | 303 + .../codexContinuationPresenter.test.ts | 299 + .../stateToProgressAdapter.test.ts | 436 +- .../syncedCustomizationBundler.test.ts | 17 +- .../aiCustomizationDiscoveryPage.test.ts | 14 +- .../aiCustomizationManagementEditor.test.ts | 25 +- .../embeddedMcpServerDetail.test.ts | 139 +- .../aiCustomization/mcpListWidget.test.ts | 249 +- .../attachments/chatImageReveal.test.ts | 272 + .../chatModelsViewModel.test.ts | 87 + .../chat/test/browser/chatPetColors.test.ts | 2 + .../cloudSandboxAgentHostService.test.ts | 134 +- .../cloudSandboxApiService.test.ts | 85 + ...loudSandboxConnectionCustomization.test.ts | 17 + .../cloudSandboxCredentialRefresh.test.ts | 26 + ...oudSandboxLegacySessionPreparation.test.ts | 11 + .../editorCloudSandboxContribution.test.ts | 25 +- .../missionControlCredentialSealing.test.ts | 28 + .../missionControlEnvironmentActions.test.ts | 268 + .../voiceSessionController.test.ts | 7 + .../chatResourceGroupWidget.test.ts | 452 ++ .../chatToolInputOutputContentPart.test.ts | 54 + .../browser/widget/chatListRenderer.test.ts | 1718 ++++- .../browser/widget/chatListWidget.test.ts | 194 +- .../modelPicker/modelPickerActionItem.test.ts | 21 +- .../modelPickerTabbedWidget.test.ts | 11 + .../modelPicker/modelPickerTelemetry.test.ts | 17 + .../common/chatEntitlementService.test.ts | 46 + .../common/model/chatToolInvocation.test.ts | 29 +- .../extensions/browser/extensionsActions.ts | 2 +- .../browser/extensionsWorkbenchService.ts | 19 +- .../extensionsWorkbenchService.test.ts | 27 + src/vs/workbench/contrib/onboarding/README.md | 2 + .../onboarding/browser/media/spotlight.css | 19 +- .../browser/spotlight/onboardingTarget.ts | 3 + .../browser/spotlight/spotlightOverlay.ts | 10 +- .../spotlight/spotlightPresentation.ts | 31 +- .../browser/spotlight/spotlightTypes.ts | 16 +- .../test/browser/spotlightOverlay.test.ts | 42 + .../browser/spotlightPresentation.test.ts | 92 + .../contrib/timeline/browser/timelinePane.ts | 20 +- .../test/browser/timelinePane.test.ts | 139 + .../agentHost/browser/codexContinuation.ts | 94 + .../browser/codexContinuationService.ts | 362 + .../test/browser/codexContinuation.test.ts | 136 + .../browser/codexContinuationService.test.ts | 447 ++ .../assignment/common/assignmentFilters.ts | 14 +- .../test/common/assignmentFilters.test.ts | 69 +- .../chat/common/chatEntitlementService.ts | 36 +- .../storage/browser/storageService.ts | 11 + .../electron-browser/storageService.ts | 11 +- .../test/browser/storageService.test.ts | 14 + .../chat/chatAgentLifecycle.fixture.ts | 5 +- .../chat/chatFixtureUtils.ts | 27 +- .../chat/chatGeneratedImages.fixture.ts | 412 ++ .../chat/chatInput.fixture.ts | 21 + .../chat/chatWidget.fixture.ts | 10 +- .../chat/media/generated-image.png | Bin 0 -> 1292979 bytes .../browser/componentFixtures/fixtureUtils.ts | 21 + .../sessions/accountMenu.fixture.ts | 22 +- ...aiCustomizationManagementEditor.fixture.ts | 93 +- .../sessions/chatCompositeBar.fixture.ts | 1 + .../codexContinuationNotice.fixture.ts | 43 + .../sessionsTitleBarWidget.fixture.ts | 57 +- .../blocks-ci-screenshots.md | 18 - 576 files changed, 47676 insertions(+), 6404 deletions(-) create mode 100644 src/vs/platform/agentHost/common/agentHostChatIdentity.ts create mode 100644 src/vs/platform/agentHost/common/agentHostSessionIdentity.ts create mode 100644 src/vs/platform/agentHost/common/codexAccountPlan.ts create mode 100644 src/vs/platform/agentHost/common/codexModelSelection.ts create mode 100644 src/vs/platform/agentHost/common/meta/agentHostSessionUrisMeta.ts create mode 100644 src/vs/platform/agentHost/common/meta/agentImageGenerationMeta.ts create mode 100644 src/vs/platform/agentHost/common/meta/codexSessionModel.ts create mode 100644 src/vs/platform/agentHost/common/meta/relayConnectionMeta.ts create mode 100644 src/vs/platform/agentHost/common/meta/vscode/agentHostSessionUrisMeta.ts create mode 100644 src/vs/platform/agentHost/common/meta/vscode/agentImageGenerationMeta.ts create mode 100644 src/vs/platform/agentHost/common/pendingEditContentUri.ts create mode 100644 src/vs/platform/agentHost/common/titleGenerationConfiguration.ts create mode 100644 src/vs/platform/agentHost/node/copilot/copilotSessionMetadataEvents.ts create mode 100644 src/vs/platform/agentHost/node/missionControl/MISSION_CONTROL.md create mode 100644 src/vs/platform/agentHost/node/missionControl/MISSION_CONTROL_PRODUCTION_GAPS.md create mode 100644 src/vs/platform/agentHost/node/missionControl/MISSION_CONTROL_SECURITY_REQUIREMENTS.md create mode 100644 src/vs/platform/agentHost/node/missionControl/missionControlAuthentication.ts create mode 100644 src/vs/platform/agentHost/node/missionControl/missionControlControl.ts create mode 100644 src/vs/platform/agentHost/node/missionControl/missionControlEnvironment.ts create mode 100644 src/vs/platform/agentHost/node/missionControl/missionControlHost.ts create mode 100644 src/vs/platform/agentHost/node/missionControl/missionControlProtocolServer.ts create mode 100644 src/vs/platform/agentHost/node/missionControl/missionControlSdkEventSource.ts create mode 100644 src/vs/platform/agentHost/node/missionControl/missionControlSessionMirror.ts create mode 100644 src/vs/platform/agentHost/node/shared/sessionIsolationTools.ts create mode 100644 src/vs/platform/agentHost/test/common/agentHostSessionIdentity.test.ts create mode 100644 src/vs/platform/agentHost/test/common/agentImageGenerationMeta.test.ts create mode 100644 src/vs/platform/agentHost/test/common/codexSessionModel.test.ts create mode 100644 src/vs/platform/agentHost/test/common/meta/relayConnectionMeta.test.ts create mode 100644 src/vs/platform/agentHost/test/common/pendingEditContentUri.test.ts create mode 100644 src/vs/platform/agentHost/test/common/titleGenerationConfiguration.test.ts create mode 100644 src/vs/platform/agentHost/test/node/copilotSessionMetadataEvents.test.ts create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/claude-regression-coverage-an-unstarted-peer-can-execute-its-first-tool-turn-after-restart.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/claude-regression-coverage-catalog-refresh-does-not-promote-restored-peer-backings-to-sessions.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/claude-regression-coverage-cold-restored-peers-retain-independent-provider-contexts.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/claude-regression-coverage-cold-resume-retains-ordered-user-and-assistant-context.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/claude-regression-coverage-completed-file-edit-remains-paired-with-its-result-after-resume.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/claude-regression-coverage-identical-prompts-remain-separate-completed-turns-after-resume.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/claude-regression-coverage-peer-tool-history-and-subsequent-edits-stay-on-the-restored-peer.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/claude-regression-coverage-successive-file-edits-retain-their-turn-ownership-after-restart.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/codex-mcp-side-channel-regression-coverage-isolates-a-concurrent-server-error-from-successful-tool-and-resource-responses.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/codex-mcp-side-channel-regression-coverage-preserves-multiple-resource-contents-with-distinct-uris-and-encodings.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/codex-mcp-side-channel-regression-coverage-preserves-ordered-text-image-and-embedded-resource-tool-content.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/codex-mcp-side-channel-regression-coverage-preserves-structured-tool-results-and-private-result-metadata.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/codex-mcp-side-channel-regression-coverage-retries-the-same-resource-after-a-transient-real-server-error.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/codex-mcp-side-channel-regression-coverage-returns-an-empty-successful-tool-result-without-fabricating-text.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/codex-mcp-side-channel-regression-coverage-returns-resource-links-that-can-be-read-on-the-same-advertised-channel.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-a-first-turn-fork-excludes-later-source-context.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-a-fork-resumes-its-own-branch-after-the-source-advances.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-a-middle-turn-fork-includes-completed-tools-but-excludes-the-next-turn.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-a-restored-peer-can-be-forked-without-changing-its-source-history.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-an-unstarted-peer-can-execute-its-first-tool-turn-after-restart.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-catalog-refresh-does-not-promote-restored-peer-backings-to-sessions.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-cold-restored-peers-retain-independent-provider-contexts.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-cold-resume-retains-ordered-user-and-assistant-context.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-completed-file-edit-remains-paired-with-its-result-after-resume.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-identical-prompts-remain-separate-completed-turns-after-resume.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-peer-tool-history-and-subsequent-edits-stay-on-the-restored-peer.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-successive-file-edits-retain-their-turn-ownership-after-restart.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-adding-managed-asks-refreshes-both-materialized-default-and-peer-chats.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-cold-host-resume-does-not-persist-a-removed-managed-ask-contribution.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-cold-host-resume-reapplies-the-current-managed-ask-contribution.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-cold-session-resume-applies-revoked-managed-capture-instead-of-the-persisted-policy.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-capturecontent-denial-overrides-local-content-opt-in.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-collector-overrides-an-inherited-trace-specific-endpoint.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-identity-denial-removes-inherited-identity-from-native-spans.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-json-protocol-overrides-inherited-generic-and-trace-specific-protobuf-protocols.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-locked-content-capture-overrides-local-content-opt-in.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-prompt-capture-can-be-reenabled-after-a-denied-session.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-prompt-capture-denial-overrides-local-content-opt-in.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-prompt-only-capture-overrides-local-content-opt-in.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-read-approval-remains-one-time-under-assisted.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-read-approval-remains-one-time-under-autoapprove.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-read-denial-blocks-the-native-tool-under-allow-all.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-resource-attributes-override-conflicts-and-retain-unrelated-environment-attributes.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-response-capture-denial-overrides-local-content-opt-in.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-response-only-capture-overrides-local-content-opt-in.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-service-identity-overrides-inherited-service-resource-attributes.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-telemetry-disablement-overrides-an-environment-selected-collector.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-write-approval-remains-one-time-under-assisted.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-write-approval-remains-one-time-under-autoapprove.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-write-denial-blocks-the-native-tool-under-allow-all.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-write-denial-prevents-mutation-under-assisted.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-write-denial-prevents-mutation-under-autoapprove.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-mcp-side-channel-regression-coverage-isolates-a-concurrent-server-error-from-successful-tool-and-resource-responses.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-mcp-side-channel-regression-coverage-preserves-multiple-resource-contents-with-distinct-uris-and-encodings.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-mcp-side-channel-regression-coverage-preserves-ordered-text-image-and-embedded-resource-tool-content.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-mcp-side-channel-regression-coverage-preserves-structured-tool-results-and-private-result-metadata.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-mcp-side-channel-regression-coverage-refreshes-schemas-for-an-existing-tool-without-a-name-change.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-mcp-side-channel-regression-coverage-refreshes-the-tool-catalog-after-a-real-server-adds-a-callable-tool.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-mcp-side-channel-regression-coverage-removes-a-tool-from-a-refreshed-catalog-while-retaining-healthy-calls.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-mcp-side-channel-regression-coverage-retries-the-same-resource-after-a-transient-real-server-error.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-mcp-side-channel-regression-coverage-returns-an-empty-successful-tool-result-without-fabricating-text.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-mcp-side-channel-regression-coverage-returns-resource-links-that-can-be-read-on-the-same-advertised-channel.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-new-sessions-revoke-managed-content-capture-without-restarting.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-new-sessions-route-managed-telemetry-to-different-collectors-without-restarting.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-a-first-turn-fork-excludes-later-source-context.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-a-fork-resumes-its-own-branch-after-the-source-advances.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-a-middle-turn-fork-includes-completed-tools-but-excludes-the-next-turn.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-a-new-peer-chat-receives-the-current-client-tool-definitions.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-a-restored-peer-can-be-forked-without-changing-its-source-history.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-an-unstarted-peer-can-execute-its-first-tool-turn-after-restart.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-catalog-refresh-does-not-promote-restored-peer-backings-to-sessions.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-client-tool-description-replacement-reaches-the-next-model-request.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-client-tool-empty-results-permit-a-subsequent-successful-call.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-client-tool-failed-results-permit-a-subsequent-successful-call.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-client-tool-multiple-text-parts-results-permit-a-subsequent-successful-call.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-client-tool-schema-replacement-reaches-a-live-provider-session.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-client-tools-added-after-materialization-execute-on-the-next-turn.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-cold-restored-peers-retain-independent-provider-contexts.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-cold-resume-binds-client-tool-execution-to-the-current-client.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-cold-resume-retains-ordered-user-and-assistant-context.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-completed-file-edit-remains-paired-with-its-result-after-resume.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-identical-prompts-remain-separate-completed-turns-after-resume.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-live-client-tool-replacement-reaches-both-materialized-chats.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-peer-tool-history-and-subsequent-edits-stay-on-the-restored-peer.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-removing-an-obsolete-client-tool-leaves-its-replacement-executable.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-successive-file-edits-retain-their-turn-ownership-after-restart.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-removing-managed-asks-refreshes-both-default-and-peer-chats-before-their-next-turn.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-removing-managed-telemetry-does-not-retain-the-previous-exporter-in-a-new-session.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/suites/terminalResilienceSuite.ts create mode 100644 src/vs/platform/agentHost/test/node/missionControlAuthentication.test.ts create mode 100644 src/vs/platform/agentHost/test/node/missionControlHost.test.ts create mode 100644 src/vs/platform/agentHost/test/node/missionControlProtocolServer.test.ts create mode 100644 src/vs/platform/agentHost/test/node/missionControlSdkEventSource.test.ts create mode 100644 src/vs/platform/agentHost/test/node/missionControlSessionMirror.test.ts create mode 100644 src/vs/platform/agentHost/test/node/sessionIsolationTools.test.ts create mode 100644 src/vs/platform/github/common/anonymousClient.ts create mode 100644 src/vs/platform/github/common/cancellation.ts create mode 100644 src/vs/platform/github/test/common/anonymousClient.test.ts create mode 100644 src/vs/platform/github/test/common/cancellation.test.ts delete mode 100644 src/vs/platform/github/test/common/githubAnonymousClient.test.ts create mode 100644 src/vs/sessions/contrib/providers/agentHost/browser/codexContinuation.contribution.ts create mode 100644 src/vs/sessions/contrib/providers/remoteAgentHost/browser/missionControlAgentHostContribution.ts delete mode 100644 src/vs/sessions/contrib/sessions/browser/automationsNewBadge.ts create mode 100644 src/vs/sessions/contrib/sessions/browser/media/sessionsListNotice.css create mode 100644 src/vs/sessions/contrib/sessions/browser/views/sessionsListNotice.ts delete mode 100644 src/vs/sessions/contrib/sessions/test/browser/automationsNewBadge.test.ts create mode 100644 src/vs/sessions/contrib/sessions/test/browser/sessionsListNotice.test.ts create mode 100644 src/vs/workbench/api/test/browser/extHostTimeline.test.ts create mode 100644 src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/codexContinuation.contribution.ts create mode 100644 src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/codexContinuationGuide.ts create mode 100644 src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/codexContinuationPresenter.ts create mode 100644 src/vs/workbench/contrib/chat/browser/attachments/chatImageGlyphSurface.ts create mode 100644 src/vs/workbench/contrib/chat/browser/attachments/chatImageReveal.css create mode 100644 src/vs/workbench/contrib/chat/browser/attachments/chatImageReveal.ts create mode 100644 src/vs/workbench/contrib/chat/browser/attachments/chatImageTextures.ts create mode 100644 src/vs/workbench/contrib/chat/browser/remoteAgentHost/missionControlCredentialSealing.ts create mode 100644 src/vs/workbench/contrib/chat/browser/remoteAgentHost/missionControlEnvironmentActions.ts create mode 100644 src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatImageGenerationProgressPart.css create mode 100644 src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatImageGenerationProgressPart.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostSessionUri.test.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/agentSessions/codexContinuationGuide.test.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/agentSessions/codexContinuationPresenter.test.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/attachments/chatImageReveal.test.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/missionControlCredentialSealing.test.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/missionControlEnvironmentActions.test.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatResourceGroupWidget.test.ts create mode 100644 src/vs/workbench/contrib/timeline/test/browser/timelinePane.test.ts create mode 100644 src/vs/workbench/services/agentHost/browser/codexContinuation.ts create mode 100644 src/vs/workbench/services/agentHost/browser/codexContinuationService.ts create mode 100644 src/vs/workbench/services/agentHost/test/browser/codexContinuation.test.ts create mode 100644 src/vs/workbench/services/agentHost/test/browser/codexContinuationService.test.ts create mode 100644 src/vs/workbench/test/browser/componentFixtures/chat/chatGeneratedImages.fixture.ts create mode 100644 src/vs/workbench/test/browser/componentFixtures/chat/media/generated-image.png create mode 100644 src/vs/workbench/test/browser/componentFixtures/sessions/codexContinuationNotice.fixture.ts diff --git a/.github/instructions/agent-host-interoperability.instructions.md b/.github/instructions/agent-host-interoperability.instructions.md index 9e96b21da19a7a..77daaeacf06932 100644 --- a/.github/instructions/agent-host-interoperability.instructions.md +++ b/.github/instructions/agent-host-interoperability.instructions.md @@ -10,3 +10,9 @@ AHP clients communicate with any conforming Agent Host, not only the local VS Co Use standard AHP fields and negotiated capabilities for shared behavior. If VS Code needs an optional extension, use a namespaced `_meta` value with a typed reader that validates its shape before use. Do not make the extension a requirement for other hosts, and do not use `_meta` to replace a standard protocol field or to justify assuming a particular host implementation. When changing an AHP client, cover a conforming host that advertises valid but different URIs and identifiers, as well as absent optional extensions. Test the behavior at the client boundary, not only against the local VS Code host. + +## Native session identity + +Follow the [Agent Host session identity contract](../../src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md#identity). Backend identities are mixed and immutable: negotiate addressing only for new allocations, never rewrite restored, discovered, adopted, or continued conversations. Keep frontend resources, session IDs, and chat fragments stable; carry the advertised provider and exact backend resource through caches and owning-connection resolution. `ahp-session` carries no provider identity and must never imply Copilot. + +Back changes with boundary tests for both version-skew directions, mixed legacy/standard resources, reconnects, opaque advertised chats, and cache reloads. Restart tests must use fresh provider instances, and discovery/deletion tests must preserve provider/backing identity rather than equating raw IDs across providers. Scope legacy visibility to positively identified legacy VS Code connections; unmarked conforming peers must not be classified as legacy. diff --git a/.github/skills/component-fixtures/SKILL.md b/.github/skills/component-fixtures/SKILL.md index 7a972f12271686..ddf630ac76c209 100644 --- a/.github/skills/component-fixtures/SKILL.md +++ b/.github/skills/component-fixtures/SKILL.md @@ -175,6 +175,8 @@ The render promise must resolve only when the fixture represents its named state - Supply deterministic display data through existing services/options, such as a single custom thinking phrase. A seeded random generator alone does not make labels independent of render order or async callback order. - Use bounded, condition-based waits that throw on failure, not fixed sleeps or extra frames that silently accept an incomplete state. +Design fixtures as **state + local inputs + an explicit readiness condition**. Supply local account icons instead of letting a mock fall back to a live avatar URL, and await decoding; `waitForFixtureCondition` polls native browser frames while preserving the virtual clock. Prefer shared DOM spinners over animated SVG backgrounds: ancestor CSS cannot stop animations inside an image. Wait for auto-hiding scrollbars to reach their intended idle state, not merely for content height to stabilize. + Fixtures that depend on native browser image decoding or resize/scroll callbacks can use `virtualTime: { enabled: false }` and explicitly await those operations. Virtual JavaScript time does not advance the browser's image decoder or layout pipeline. For async fixtures with paint-order-sensitive edges, `deferPaint: true` keeps the headless fixture transparent until rendering has finished, without changing its layout or interactive preview. It prevents intermediate paints from affecting the final rasterization; it does **not** replace awaiting readiness. diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index 889fac3759b303..7d7ef75706c1ad 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -1261,6 +1261,10 @@ "--monaco-editor-warning-decoration", "--animation-angle", "--animation-opacity", + "--chat-generated-image-max-height", + "--chat-image-reveal-frame", + "--chat-image-reveal-frame-width", + "--chat-image-reveal-image", "--chat-input-anim-angle", "--chat-input-anim-duration", "--chat-input-control-height", diff --git a/eslint.config.js b/eslint.config.js index 7611f24039b626..56088d80488c7d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -203,6 +203,40 @@ export default defineConfig( ] } }, + { + files: [ + 'src/vs/platform/agentHost/test/**/missionControl*.test.ts', + 'src/vs/platform/agentHost/test/**/protocolServerHandler.test.ts', + 'src/vs/platform/agentHost/test/**/agentHostProtocolClient.test.ts', + 'src/vs/platform/agentHost/test/**/webPubSubRelayTransport.test.ts', + 'src/vs/platform/agentHost/test/common/webPubSub/*.test.ts', + 'src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandbox*.test.ts', + 'src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHost.contribution.test.ts', + ], + plugins: { + 'agent-host-test': { + rules: { + 'no-nested-tests': { + meta: { + type: 'problem', + schema: [], + messages: { nested: 'Declare tests in the suite, not inside another test; Mocha does not execute nested declarations.' }, + }, + create(context) { + return { + 'CallExpression[callee.name="test"] CallExpression[callee.name="test"]'(node) { + context.report({ node, messageId: 'nested' }); + }, + }; + }, + }, + }, + }, + }, + rules: { + 'agent-host-test/no-nested-tests': 'error', + }, + }, // Disallow common telemetry properties in event data { files: [ @@ -1716,6 +1750,7 @@ export default defineConfig( '@modelcontextprotocol/sdk/**/*', // used by agentHost for Claude client-tool MCP result types (Phase 10) '@github/copilot-sdk', 'zod', // used by agentHost for Claude client-tool MCP input schemas + { 'when': 'hasNode', 'pattern': 'libsodium-wrappers' }, { 'when': 'test', 'pattern': 'events' diff --git a/extensions/copilot/src/platform/telemetry/test/node/assignmentFilters.spec.ts b/extensions/copilot/src/platform/telemetry/test/node/assignmentFilters.spec.ts index 2bdd0874c18a15..02e01c91d08a02 100644 --- a/extensions/copilot/src/platform/telemetry/test/node/assignmentFilters.spec.ts +++ b/extensions/copilot/src/platform/telemetry/test/node/assignmentFilters.spec.ts @@ -93,6 +93,41 @@ describe('CopilotAssignmentsFilterProvider', () => { ]); }); + it('preserves VS Code team targeting and internal organization precedence across token changes', () => { + const { tokenStore, provider } = createProvider(); + const organizations = [ + ['a5db0bcaae94032fe715fb34a5e4bce2'], + ['4535c7beffc844b46bb1ed4aa04d759a'], + ['551cca60ce19654d894e786220822482'], + ['a5db0bcaae94032fe715fb34a5e4bce2', '4535c7beffc844b46bb1ed4aa04d759a', '551cca60ce19654d894e786220822482'], + ['unrecognized-org'], + [], + ]; + const tokens = [ + ...organizations.map(organization_list => new CopilotToken(createTestExtendedTokenInfo({ organization_list }))), + new CopilotToken(createTestExtendedTokenInfo({ isVscodeTeamMember: true })), + undefined, + ]; + + expect(tokens.map(token => { + tokenStore.copilotToken = token; + const filters = provider.getFilters(); + return [ + filters.get('github_core_ghmsftorexternal'), + filters.get('github_core_isghormsftstaff'), + ]; + })).toEqual([ + ['microsoft', '1'], + ['github', '1'], + ['vscode', '1'], + ['vscode', '1'], + ['external', '0'], + ['external', '0'], + ['vscode', '1'], + ['external', '0'], + ]); + }); + it('preserves legacy CLS user-kind values and precedence', () => { const { tokenStore, provider } = createProvider(); const knownOrganizations = [ diff --git a/extensions/copilot/src/platform/telemetry/vscode-node/microsoftExperimentationService.ts b/extensions/copilot/src/platform/telemetry/vscode-node/microsoftExperimentationService.ts index dc21066ef1e345..9459fb059ca3e9 100644 --- a/extensions/copilot/src/platform/telemetry/vscode-node/microsoftExperimentationService.ts +++ b/extensions/copilot/src/platform/telemetry/vscode-node/microsoftExperimentationService.ts @@ -254,7 +254,7 @@ export class CopilotAssignmentsFilterProvider implements IExperimentationFilterP filters.set('github_core_organizationid', token?.organizationList.join(',')); filters.set('github_core_businessid', token?.enterpriseList.join(',')); filters.set('github_core_isghormsftstaff', internalOrg ? '1' : '0'); - filters.set('github_core_ghmsftorexternal', internalOrg === 'github' ? 'github' : (internalOrg === 'microsoft' || internalOrg === 'vscode') ? 'microsoft' : 'external'); + filters.set('github_core_ghmsftorexternal', internalOrg ?? 'external'); filters.set('github_core_userkind', token?.userKind || undefined); filters.set('github_core_copilotsku', token?.sku); filters.set('github_core_issn', token?.isSn() ? '1' : '0'); diff --git a/package-lock.json b/package-lock.json index d3044e0bc7d383..43ce956fac6113 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "dependencies": { "@anthropic-ai/sdk": "^0.82.0", "@devcontainers/cli": "0.88.0", - "@github/copilot-sdk": "1.0.17-preview.4", + "@github/copilot-sdk": "1.0.17-preview.5", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/dev-tunnels-connections": "^1.3.41", @@ -61,6 +61,7 @@ "jschardet": "3.1.4", "katex": "^0.16.22", "kerberos": "2.1.1", + "libsodium-wrappers": "^0.8.4", "minimist": "^1.2.8", "native-is-elevated": "0.9.0", "native-keymap": "^3.3.5", @@ -1170,9 +1171,9 @@ } }, "node_modules/@github/copilot-sdk": { - "version": "1.0.17-preview.4", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.17-preview.4.tgz", - "integrity": "sha512-tt5pyDLUjiaxJSMCU2HRDLRSFOA4mIFpI6+360EC0jqNDmhlaeJy88YwO6zDO77D21l8lGt8INBj+IYMbPb30Q==", + "version": "1.0.17-preview.5", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.17-preview.5.tgz", + "integrity": "sha512-Nj/SaURylTh5PqyONpEd0JHc7C1s+/cSPvhKf1ZST+Yn+91exkmQSKuZjABuACxQhtJ/vbQ6QbmiXPx/6S3gyg==", "license": "MIT", "dependencies": { "koffi": "3.2.1", @@ -1183,20 +1184,20 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@github/copilot-sdk-darwin-arm64": "1.0.17-preview.4", - "@github/copilot-sdk-darwin-x64": "1.0.17-preview.4", - "@github/copilot-sdk-linux-arm64": "1.0.17-preview.4", - "@github/copilot-sdk-linux-x64": "1.0.17-preview.4", - "@github/copilot-sdk-linuxmusl-arm64": "1.0.17-preview.4", - "@github/copilot-sdk-linuxmusl-x64": "1.0.17-preview.4", - "@github/copilot-sdk-win32-arm64": "1.0.17-preview.4", - "@github/copilot-sdk-win32-x64": "1.0.17-preview.4" + "@github/copilot-sdk-darwin-arm64": "1.0.17-preview.5", + "@github/copilot-sdk-darwin-x64": "1.0.17-preview.5", + "@github/copilot-sdk-linux-arm64": "1.0.17-preview.5", + "@github/copilot-sdk-linux-x64": "1.0.17-preview.5", + "@github/copilot-sdk-linuxmusl-arm64": "1.0.17-preview.5", + "@github/copilot-sdk-linuxmusl-x64": "1.0.17-preview.5", + "@github/copilot-sdk-win32-arm64": "1.0.17-preview.5", + "@github/copilot-sdk-win32-x64": "1.0.17-preview.5" } }, "node_modules/@github/copilot-sdk-darwin-arm64": { - "version": "1.0.17-preview.4", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-arm64/-/copilot-sdk-darwin-arm64-1.0.17-preview.4.tgz", - "integrity": "sha512-cufab0CWxFH0wwIw1/dlPNx4gFP/PJoL4+tUQ/d6lZ+SWX4/2VQgRJCQ97qe9Nqql3YWDikiJTTHXQ55xkmmRA==", + "version": "1.0.17-preview.5", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-arm64/-/copilot-sdk-darwin-arm64-1.0.17-preview.5.tgz", + "integrity": "sha512-VNwE78kymAK142c4vkZDYJBbB38GZag29sw2MAC42pQyMkf/a61gunH9k4FECgFnOTRCW1k0ouUJmqBkHwsgTw==", "cpu": [ "arm64" ], @@ -1207,9 +1208,9 @@ ] }, "node_modules/@github/copilot-sdk-darwin-x64": { - "version": "1.0.17-preview.4", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-x64/-/copilot-sdk-darwin-x64-1.0.17-preview.4.tgz", - "integrity": "sha512-4kj7nU3oXCFFm24d6FijBkuiLTCBtfiI8qEHsDcSxfgPdPnwV+9osT/EAZRKPTh3DYGwqTO7i+bIHEHgogd+Wg==", + "version": "1.0.17-preview.5", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-x64/-/copilot-sdk-darwin-x64-1.0.17-preview.5.tgz", + "integrity": "sha512-XiM1G9k91AF+F6L5PvcQ6LFhFyN+Vo5o93cgY8rmUXP7sWUDBZljy84VL/wsPBjBoKBZj33utyZedHIU/2IfoA==", "cpu": [ "x64" ], @@ -1220,9 +1221,9 @@ ] }, "node_modules/@github/copilot-sdk-linux-arm64": { - "version": "1.0.17-preview.4", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-arm64/-/copilot-sdk-linux-arm64-1.0.17-preview.4.tgz", - "integrity": "sha512-aOHcV5YiFGhtxh+H3qhOuMzktyhwuepGl/jWkWu2vTLy2oztpMnxmoRgwJaj9NdNQoiM8PO13YjbFhbb3TGaOA==", + "version": "1.0.17-preview.5", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-arm64/-/copilot-sdk-linux-arm64-1.0.17-preview.5.tgz", + "integrity": "sha512-tDiZnlFfwNYqh2yoqpnRRJjvO47EInrIXn6vMsIMXAO3sAXckSyitP10qWQkOEbhgVjVczwsLE/df64356CyOg==", "cpu": [ "arm64" ], @@ -1236,9 +1237,9 @@ ] }, "node_modules/@github/copilot-sdk-linux-x64": { - "version": "1.0.17-preview.4", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-x64/-/copilot-sdk-linux-x64-1.0.17-preview.4.tgz", - "integrity": "sha512-W98/SrLJXVk5xn8GgvUxNs8wHM10nPe5Ff3fU9vKb4Kb0aS2KyxoAhC4eoNn8VRB2XDW2wFTBvUJAutCiWGL+Q==", + "version": "1.0.17-preview.5", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-x64/-/copilot-sdk-linux-x64-1.0.17-preview.5.tgz", + "integrity": "sha512-eYOibqL5OmI1ulyUcA76gGswzzHXp4Rmvwg4mYGUTFDql9eNy976ASaEwgQTie1FS5gQUKNeiAiLRF9hsrsVHw==", "cpu": [ "x64" ], @@ -1252,9 +1253,9 @@ ] }, "node_modules/@github/copilot-sdk-linuxmusl-arm64": { - "version": "1.0.17-preview.4", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-arm64/-/copilot-sdk-linuxmusl-arm64-1.0.17-preview.4.tgz", - "integrity": "sha512-hym02Fv/JuqjmmMqs4E+XFEgx8nzIpIdMLSr5CFpMLWVVKQYBhY8x6r+03qXhfutUWlcoL5f8gK/pdOR23k9jw==", + "version": "1.0.17-preview.5", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-arm64/-/copilot-sdk-linuxmusl-arm64-1.0.17-preview.5.tgz", + "integrity": "sha512-ePJQJg3ohITIQhU35MTSnxKLxweDhaFitohRusO7G5Z4pkfntWgcKdv0rGOejcicDJ7dTiEqwTVSC3Ed1Oe7+g==", "cpu": [ "arm64" ], @@ -1268,9 +1269,9 @@ ] }, "node_modules/@github/copilot-sdk-linuxmusl-x64": { - "version": "1.0.17-preview.4", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-x64/-/copilot-sdk-linuxmusl-x64-1.0.17-preview.4.tgz", - "integrity": "sha512-IhjdLXtUSV8lSZiWsciGHD2iwv3nS2pAwLMywfgB1GozouBDIfWZJlbTUX5Jqv4JeZjE7lpvD5e+Sc11HL6kQQ==", + "version": "1.0.17-preview.5", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-x64/-/copilot-sdk-linuxmusl-x64-1.0.17-preview.5.tgz", + "integrity": "sha512-pzxYvWjSzFNiEV+hBzkRhlugGP3C7dQ8jsDYdrdWO7brE8y2/FGcuganmdQWevMPb9V7vQZEbk/SvelC48kmGw==", "cpu": [ "x64" ], @@ -1284,9 +1285,9 @@ ] }, "node_modules/@github/copilot-sdk-win32-arm64": { - "version": "1.0.17-preview.4", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-arm64/-/copilot-sdk-win32-arm64-1.0.17-preview.4.tgz", - "integrity": "sha512-MxJJiT2DoDXi3a6dPY/DwMbJPweK75ywlIIbK6Agnof055euC4st3bKMUXz8Nuq4pWzXU3HEWFUVWnNr/63TNw==", + "version": "1.0.17-preview.5", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-arm64/-/copilot-sdk-win32-arm64-1.0.17-preview.5.tgz", + "integrity": "sha512-H83RaEde07pgVoL978Ala7KaOAPsgNIwMdZRLB2i/BIVIEN7gpceBNVZBYw8l5a/mVGb3fO+UMhJxgUNjQfNDw==", "cpu": [ "arm64" ], @@ -1297,9 +1298,9 @@ ] }, "node_modules/@github/copilot-sdk-win32-x64": { - "version": "1.0.17-preview.4", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-x64/-/copilot-sdk-win32-x64-1.0.17-preview.4.tgz", - "integrity": "sha512-NGC8k/WHw6lP6O6h/nJiw3lBaaaMjb1hu+urr+Wpa9EP9a/PFL4rWXpxixgQT4qym7EAVY+EE1vSTA8jCi7cAA==", + "version": "1.0.17-preview.5", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-x64/-/copilot-sdk-win32-x64-1.0.17-preview.5.tgz", + "integrity": "sha512-AA7BQoNXZl9bbPSqyN2pDXm0Nw2cSNvviQDqU4rMXGX10Ye8RfiLXMMqIGQn8vYYhR1wY+D8/gQCDlv22HvGng==", "cpu": [ "x64" ], @@ -14231,6 +14232,21 @@ "node": ">= 0.8.0" } }, + "node_modules/libsodium": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/libsodium/-/libsodium-0.8.4.tgz", + "integrity": "sha512-lMcYaRi0zcs7tarATsQUYC7rstliIXZuoq0c6zXSgNtSNtdvBgkSegjWhpMJAXzKX3SUSwIp7+zEsob+j3LuRw==", + "license": "ISC" + }, + "node_modules/libsodium-wrappers": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/libsodium-wrappers/-/libsodium-wrappers-0.8.4.tgz", + "integrity": "sha512-mu8aAWucZjTB5O/BtGXtW4e1agy7uHxNYG7zPthmmD1jU43LCDmSWZLN4JhflbdPXj3yDO4lxM1O9hLDgIOXDw==", + "license": "ISC", + "dependencies": { + "libsodium": "^0.8.0" + } + }, "node_modules/lie": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", diff --git a/package.json b/package.json index cbabf9bd0f8784..efbd7822f00fcd 100644 --- a/package.json +++ b/package.json @@ -102,7 +102,7 @@ "dependencies": { "@anthropic-ai/sdk": "^0.82.0", "@devcontainers/cli": "0.88.0", - "@github/copilot-sdk": "1.0.17-preview.4", + "@github/copilot-sdk": "1.0.17-preview.5", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/dev-tunnels-connections": "^1.3.41", @@ -151,6 +151,7 @@ "jschardet": "3.1.4", "katex": "^0.16.22", "kerberos": "2.1.1", + "libsodium-wrappers": "^0.8.4", "minimist": "^1.2.8", "native-is-elevated": "0.9.0", "native-keymap": "^3.3.5", @@ -325,5 +326,5 @@ "@vscode/windows-ca-certs@0.3.4": true, "@vscode/windows-process-tree@0.8.0": true }, - "copilotRuntimeVersion": "1.0.92-4" + "copilotRuntimeVersion": "1.0.92-5" } diff --git a/remote/package-lock.json b/remote/package-lock.json index 0300e36651d483..7ccbdc37ad67f6 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0", "dependencies": { "@devcontainers/cli": "0.88.0", - "@github/copilot-sdk": "1.0.17-preview.4", + "@github/copilot-sdk": "1.0.17-preview.5", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/mxc-sdk": "0.9.0", @@ -45,6 +45,7 @@ "jschardet": "3.1.4", "katex": "^0.16.22", "kerberos": "2.1.1", + "libsodium-wrappers": "^0.8.4", "minimist": "^1.2.8", "node-addon-api": "^6.0.0", "node-pty": "^1.2.0-beta.15", @@ -73,9 +74,9 @@ } }, "node_modules/@github/copilot-sdk": { - "version": "1.0.17-preview.4", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.17-preview.4.tgz", - "integrity": "sha512-tt5pyDLUjiaxJSMCU2HRDLRSFOA4mIFpI6+360EC0jqNDmhlaeJy88YwO6zDO77D21l8lGt8INBj+IYMbPb30Q==", + "version": "1.0.17-preview.5", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.17-preview.5.tgz", + "integrity": "sha512-Nj/SaURylTh5PqyONpEd0JHc7C1s+/cSPvhKf1ZST+Yn+91exkmQSKuZjABuACxQhtJ/vbQ6QbmiXPx/6S3gyg==", "license": "MIT", "dependencies": { "koffi": "3.2.1", @@ -86,20 +87,20 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@github/copilot-sdk-darwin-arm64": "1.0.17-preview.4", - "@github/copilot-sdk-darwin-x64": "1.0.17-preview.4", - "@github/copilot-sdk-linux-arm64": "1.0.17-preview.4", - "@github/copilot-sdk-linux-x64": "1.0.17-preview.4", - "@github/copilot-sdk-linuxmusl-arm64": "1.0.17-preview.4", - "@github/copilot-sdk-linuxmusl-x64": "1.0.17-preview.4", - "@github/copilot-sdk-win32-arm64": "1.0.17-preview.4", - "@github/copilot-sdk-win32-x64": "1.0.17-preview.4" + "@github/copilot-sdk-darwin-arm64": "1.0.17-preview.5", + "@github/copilot-sdk-darwin-x64": "1.0.17-preview.5", + "@github/copilot-sdk-linux-arm64": "1.0.17-preview.5", + "@github/copilot-sdk-linux-x64": "1.0.17-preview.5", + "@github/copilot-sdk-linuxmusl-arm64": "1.0.17-preview.5", + "@github/copilot-sdk-linuxmusl-x64": "1.0.17-preview.5", + "@github/copilot-sdk-win32-arm64": "1.0.17-preview.5", + "@github/copilot-sdk-win32-x64": "1.0.17-preview.5" } }, "node_modules/@github/copilot-sdk-darwin-arm64": { - "version": "1.0.17-preview.4", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-arm64/-/copilot-sdk-darwin-arm64-1.0.17-preview.4.tgz", - "integrity": "sha512-cufab0CWxFH0wwIw1/dlPNx4gFP/PJoL4+tUQ/d6lZ+SWX4/2VQgRJCQ97qe9Nqql3YWDikiJTTHXQ55xkmmRA==", + "version": "1.0.17-preview.5", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-arm64/-/copilot-sdk-darwin-arm64-1.0.17-preview.5.tgz", + "integrity": "sha512-VNwE78kymAK142c4vkZDYJBbB38GZag29sw2MAC42pQyMkf/a61gunH9k4FECgFnOTRCW1k0ouUJmqBkHwsgTw==", "cpu": [ "arm64" ], @@ -110,9 +111,9 @@ ] }, "node_modules/@github/copilot-sdk-darwin-x64": { - "version": "1.0.17-preview.4", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-x64/-/copilot-sdk-darwin-x64-1.0.17-preview.4.tgz", - "integrity": "sha512-4kj7nU3oXCFFm24d6FijBkuiLTCBtfiI8qEHsDcSxfgPdPnwV+9osT/EAZRKPTh3DYGwqTO7i+bIHEHgogd+Wg==", + "version": "1.0.17-preview.5", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-darwin-x64/-/copilot-sdk-darwin-x64-1.0.17-preview.5.tgz", + "integrity": "sha512-XiM1G9k91AF+F6L5PvcQ6LFhFyN+Vo5o93cgY8rmUXP7sWUDBZljy84VL/wsPBjBoKBZj33utyZedHIU/2IfoA==", "cpu": [ "x64" ], @@ -123,9 +124,9 @@ ] }, "node_modules/@github/copilot-sdk-linux-arm64": { - "version": "1.0.17-preview.4", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-arm64/-/copilot-sdk-linux-arm64-1.0.17-preview.4.tgz", - "integrity": "sha512-aOHcV5YiFGhtxh+H3qhOuMzktyhwuepGl/jWkWu2vTLy2oztpMnxmoRgwJaj9NdNQoiM8PO13YjbFhbb3TGaOA==", + "version": "1.0.17-preview.5", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-arm64/-/copilot-sdk-linux-arm64-1.0.17-preview.5.tgz", + "integrity": "sha512-tDiZnlFfwNYqh2yoqpnRRJjvO47EInrIXn6vMsIMXAO3sAXckSyitP10qWQkOEbhgVjVczwsLE/df64356CyOg==", "cpu": [ "arm64" ], @@ -139,9 +140,9 @@ ] }, "node_modules/@github/copilot-sdk-linux-x64": { - "version": "1.0.17-preview.4", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-x64/-/copilot-sdk-linux-x64-1.0.17-preview.4.tgz", - "integrity": "sha512-W98/SrLJXVk5xn8GgvUxNs8wHM10nPe5Ff3fU9vKb4Kb0aS2KyxoAhC4eoNn8VRB2XDW2wFTBvUJAutCiWGL+Q==", + "version": "1.0.17-preview.5", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linux-x64/-/copilot-sdk-linux-x64-1.0.17-preview.5.tgz", + "integrity": "sha512-eYOibqL5OmI1ulyUcA76gGswzzHXp4Rmvwg4mYGUTFDql9eNy976ASaEwgQTie1FS5gQUKNeiAiLRF9hsrsVHw==", "cpu": [ "x64" ], @@ -155,9 +156,9 @@ ] }, "node_modules/@github/copilot-sdk-linuxmusl-arm64": { - "version": "1.0.17-preview.4", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-arm64/-/copilot-sdk-linuxmusl-arm64-1.0.17-preview.4.tgz", - "integrity": "sha512-hym02Fv/JuqjmmMqs4E+XFEgx8nzIpIdMLSr5CFpMLWVVKQYBhY8x6r+03qXhfutUWlcoL5f8gK/pdOR23k9jw==", + "version": "1.0.17-preview.5", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-arm64/-/copilot-sdk-linuxmusl-arm64-1.0.17-preview.5.tgz", + "integrity": "sha512-ePJQJg3ohITIQhU35MTSnxKLxweDhaFitohRusO7G5Z4pkfntWgcKdv0rGOejcicDJ7dTiEqwTVSC3Ed1Oe7+g==", "cpu": [ "arm64" ], @@ -171,9 +172,9 @@ ] }, "node_modules/@github/copilot-sdk-linuxmusl-x64": { - "version": "1.0.17-preview.4", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-x64/-/copilot-sdk-linuxmusl-x64-1.0.17-preview.4.tgz", - "integrity": "sha512-IhjdLXtUSV8lSZiWsciGHD2iwv3nS2pAwLMywfgB1GozouBDIfWZJlbTUX5Jqv4JeZjE7lpvD5e+Sc11HL6kQQ==", + "version": "1.0.17-preview.5", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-linuxmusl-x64/-/copilot-sdk-linuxmusl-x64-1.0.17-preview.5.tgz", + "integrity": "sha512-pzxYvWjSzFNiEV+hBzkRhlugGP3C7dQ8jsDYdrdWO7brE8y2/FGcuganmdQWevMPb9V7vQZEbk/SvelC48kmGw==", "cpu": [ "x64" ], @@ -187,9 +188,9 @@ ] }, "node_modules/@github/copilot-sdk-win32-arm64": { - "version": "1.0.17-preview.4", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-arm64/-/copilot-sdk-win32-arm64-1.0.17-preview.4.tgz", - "integrity": "sha512-MxJJiT2DoDXi3a6dPY/DwMbJPweK75ywlIIbK6Agnof055euC4st3bKMUXz8Nuq4pWzXU3HEWFUVWnNr/63TNw==", + "version": "1.0.17-preview.5", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-arm64/-/copilot-sdk-win32-arm64-1.0.17-preview.5.tgz", + "integrity": "sha512-H83RaEde07pgVoL978Ala7KaOAPsgNIwMdZRLB2i/BIVIEN7gpceBNVZBYw8l5a/mVGb3fO+UMhJxgUNjQfNDw==", "cpu": [ "arm64" ], @@ -200,9 +201,9 @@ ] }, "node_modules/@github/copilot-sdk-win32-x64": { - "version": "1.0.17-preview.4", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-x64/-/copilot-sdk-win32-x64-1.0.17-preview.4.tgz", - "integrity": "sha512-NGC8k/WHw6lP6O6h/nJiw3lBaaaMjb1hu+urr+Wpa9EP9a/PFL4rWXpxixgQT4qym7EAVY+EE1vSTA8jCi7cAA==", + "version": "1.0.17-preview.5", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk-win32-x64/-/copilot-sdk-win32-x64-1.0.17-preview.5.tgz", + "integrity": "sha512-AA7BQoNXZl9bbPSqyN2pDXm0Nw2cSNvviQDqU4rMXGX10Ye8RfiLXMMqIGQn8vYYhR1wY+D8/gQCDlv22HvGng==", "cpu": [ "x64" ], @@ -1617,6 +1618,21 @@ "@koromix/koffi-win32-x64": "3.2.1" } }, + "node_modules/libsodium": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/libsodium/-/libsodium-0.8.4.tgz", + "integrity": "sha512-lMcYaRi0zcs7tarATsQUYC7rstliIXZuoq0c6zXSgNtSNtdvBgkSegjWhpMJAXzKX3SUSwIp7+zEsob+j3LuRw==", + "license": "ISC" + }, + "node_modules/libsodium-wrappers": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/libsodium-wrappers/-/libsodium-wrappers-0.8.4.tgz", + "integrity": "sha512-mu8aAWucZjTB5O/BtGXtW4e1agy7uHxNYG7zPthmmD1jU43LCDmSWZLN4JhflbdPXj3yDO4lxM1O9hLDgIOXDw==", + "license": "ISC", + "dependencies": { + "libsodium": "^0.8.0" + } + }, "node_modules/lru-cache": { "version": "11.5.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", diff --git a/remote/package.json b/remote/package.json index 9f718de08d7811..8189c95fb2291c 100644 --- a/remote/package.json +++ b/remote/package.json @@ -4,7 +4,7 @@ "private": true, "dependencies": { "@devcontainers/cli": "0.88.0", - "@github/copilot-sdk": "1.0.17-preview.4", + "@github/copilot-sdk": "1.0.17-preview.5", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/mxc-sdk": "0.9.0", @@ -40,6 +40,7 @@ "jschardet": "3.1.4", "katex": "^0.16.22", "kerberos": "2.1.1", + "libsodium-wrappers": "^0.8.4", "minimist": "^1.2.8", "node-addon-api": "^6.0.0", "node-pty": "^1.2.0-beta.15", @@ -78,5 +79,5 @@ "@vscode/windows-ca-certs@0.3.4": true, "@vscode/windows-process-tree@0.8.0": true }, - "copilotRuntimeVersion": "1.0.92-4" + "copilotRuntimeVersion": "1.0.92-5" } diff --git a/src/vs/base/browser/ui/grid/gridview.ts b/src/vs/base/browser/ui/grid/gridview.ts index 47de4f1de301eb..f8a596d79fd6de 100644 --- a/src/vs/base/browser/ui/grid/gridview.ts +++ b/src/vs/base/browser/ui/grid/gridview.ts @@ -1135,6 +1135,7 @@ export class GridView implements IDisposable { return; } + this.exitMaximizedView(); const { size, orthogonalSize, absoluteOffset, absoluteOrthogonalOffset } = this._root; this.root = flipNode(this._root, orthogonalSize, size); this.root.layout(size, 0, { orthogonalSize, absoluteOffset: absoluteOrthogonalOffset, absoluteOrthogonalOffset: absoluteOffset, absoluteSize: size, absoluteOrthogonalSize: orthogonalSize }); @@ -1158,6 +1159,7 @@ export class GridView implements IDisposable { } private maximizedNode: LeafNode | undefined = undefined; + private maximizedVisibleNodes: ReadonlySet | undefined; private readonly _onDidChangeViewMaximized = new Emitter(); readonly onDidChangeViewMaximized = this._onDidChangeViewMaximized.event; @@ -1324,11 +1326,15 @@ export class GridView implements IDisposable { if (sibling instanceof BranchNode) { sizes.splice(parentIndex, 1, ...sibling.children.map(c => c.size)); + const siblingChildSizes = sibling.children.map((child, index) => { + const cachedVisibleSize = sibling.getChildCachedVisibleSize(index); + return typeof cachedVisibleSize === 'number' ? Sizing.Invisible(cachedVisibleSize) : child.size; + }); const siblingChildren = sibling.removeAllChildren(); for (let i = 0; i < siblingChildren.length; i++) { - grandParent.addChild(siblingChildren[i], siblingChildren[i].size, parentIndex + i); + grandParent.addChild(siblingChildren[i], siblingChildSizes[i], parentIndex + i); } } else { const newSibling = new LeafNode(sibling.view, orthogonal(sibling.orientation), this.layoutController, sibling.size); @@ -1554,6 +1560,19 @@ export class GridView implements IDisposable { } const excludeViewSet = new Set(excludeViews); + const visibleNodes = new Set(); + + function collectVisibleNodes(parent: BranchNode): void { + for (let index = 0; index < parent.children.length; index++) { + const child = parent.children[index]; + if (parent.isChildVisible(index)) { + visibleNodes.add(child); + } + if (child instanceof BranchNode) { + collectVisibleNodes(child); + } + } + } function hideAllViewsBut(parent: BranchNode, exclude: LeafNode): void { for (let i = 0; i < parent.children.length; i++) { @@ -1568,8 +1587,10 @@ export class GridView implements IDisposable { } } + collectVisibleNodes(this.root); hideAllViewsBut(this.root, nodeToMaximize); + this.maximizedVisibleNodes = visibleNodes; this.maximizedNode = nodeToMaximize; this._onDidChangeViewMaximized.fire(true); } @@ -1578,22 +1599,22 @@ export class GridView implements IDisposable { if (!this.maximizedNode) { return; } + const visibleNodes = this.maximizedVisibleNodes; this.maximizedNode = undefined; + this.maximizedVisibleNodes = undefined; - // When hiding a view, it's previous size is cached. - // To restore the sizes of all views, they need to be made visible in reverse order. - function showViewsInReverseOrder(parent: BranchNode): void { + // Restoring visible views in reverse order also restores their cached sizes. + function restoreViewsInReverseOrder(parent: BranchNode): void { for (let index = parent.children.length - 1; index >= 0; index--) { const child = parent.children[index]; - if (child instanceof LeafNode) { - parent.setChildVisible(index, true); - } else { - showViewsInReverseOrder(child); + if (child instanceof BranchNode) { + restoreViewsInReverseOrder(child); } + parent.setChildVisible(index, visibleNodes?.has(child) ?? true); } } - showViewsInReverseOrder(this.root); + restoreViewsInReverseOrder(this.root); this._onDidChangeViewMaximized.fire(false); } @@ -1668,7 +1689,6 @@ export class GridView implements IDisposable { setViewVisible(location: GridLocation, visible: boolean): void { if (this.hasMaximizedView()) { this.exitMaximizedView(); - return; } const [rest, index] = tail(location); diff --git a/src/vs/base/browser/ui/list/listView.ts b/src/vs/base/browser/ui/list/listView.ts index c1050dd3ce57d8..7a1983f9cd8ad8 100644 --- a/src/vs/base/browser/ui/list/listView.ts +++ b/src/vs/base/browser/ui/list/listView.ts @@ -935,19 +935,31 @@ export class ListView implements IListView { } const insertedItems: IItem[] = []; + const insertRows = () => { + for (const range of rangesToInsert) { + for (let i = range.end - 1; i >= range.start; i--) { + this.insertItemInDOM(i); + insertedItems.push(this.items[i]); + } + } + }; + // Measuring new rows can reveal a row that the estimated scroll position would otherwise recycle. + const insertBeforeRemove = this.supportDynamicHeights && rangesToInsert.some(range => + this.items.slice(range.start, range.end).some(item => this.shouldProbeDynamicHeight(item))); this.cache.transact(() => { + if (insertBeforeRemove) { + insertRows(); + } + for (const range of rangesToRemove) { for (let i = range.start; i < range.end; i++) { this.removeItemFromDOM(i, onScroll); } } - for (const range of rangesToInsert) { - for (let i = range.end - 1; i >= range.start; i--) { - this.insertItemInDOM(i); - insertedItems.push(this.items[i]); - } + if (!insertBeforeRemove) { + insertRows(); } }); diff --git a/src/vs/base/browser/ui/pixelSpinner/pixelSpinner.css b/src/vs/base/browser/ui/pixelSpinner/pixelSpinner.css index 59910e49d76454..16e311c9681f78 100644 --- a/src/vs/base/browser/ui/pixelSpinner/pixelSpinner.css +++ b/src/vs/base/browser/ui/pixelSpinner/pixelSpinner.css @@ -161,7 +161,8 @@ } @media (prefers-reduced-motion: reduce) { - .monaco-pixel-spinner .monaco-pixel-spinner-dot { + .monaco-pixel-spinner .monaco-pixel-spinner-dot, + .monaco-pixel-spinner.monaco-pixel-spinner-ring .monaco-pixel-spinner-dot { animation: none; opacity: 1; transform: translateY(0); diff --git a/src/vs/base/browser/ui/toolbar/toolbar.ts b/src/vs/base/browser/ui/toolbar/toolbar.ts index d758c4302b8c92..58c388b62c0298 100644 --- a/src/vs/base/browser/ui/toolbar/toolbar.ts +++ b/src/vs/base/browser/ui/toolbar/toolbar.ts @@ -36,6 +36,7 @@ export interface IToolBarResponsiveBehaviorOptions { readonly getOverflowAction?: (action: IAction, getAnchor: () => HTMLElement | undefined) => IAction; readonly observedElement?: HTMLElement; readonly getAvailableWidth?: () => number; + readonly reserveMinWidth?: boolean; } export interface IToolBarOptions { @@ -80,6 +81,8 @@ export interface IToolBarOptions { * - `getActionMinWidth`: Optional per-action minimum width override in pixels. * - `allowOverflow`: Whether actions may move into the overflow menu, or a callback that decides from current presentation state. * - `getOverflowAction`: Replaces an action only while it is rendered in the overflow menu. + * - `reserveMinWidth`: Whether the toolbar sets a CSS minimum width on itself and its container so that its `minItems` always fit. Defaults to `true`. + * When `false`, the surrounding layout may shrink the toolbar below that width, in which case the `minItems` overflow too. */ responsiveBehavior?: IToolBarResponsiveBehaviorOptions; } @@ -101,6 +104,7 @@ export class ToolBar extends Disposable { private originalPrimaryActions: ReadonlyArray = []; private originalSecondaryActions: ReadonlyArray = []; private hiddenActions: IAction[] = []; + private minItemsWidth = 0; private readonly disposables = this._register(new DisposableStore()); constructor(private readonly container: HTMLElement, contextMenuProvider: IContextMenuProvider, options: IToolBarOptions = { orientation: ActionsOrientation.HORIZONTAL }) { @@ -347,12 +351,12 @@ export class ToolBar extends Disposable { } const separatorWidth = this.options.trailingSeparator && !this.actionBar.isEmpty() ? this.actionBar.getWidth(this.actionBar.length() - 1) + ACTION_PADDING : 0; - this.container.style.minWidth = `${primaryActionsMinWidth + overflowWidth + separatorWidth}px`; - this.element.style.minWidth = `${primaryActionsMinWidth + overflowWidth + separatorWidth}px`; + this.minItemsWidth = primaryActionsMinWidth + overflowWidth + separatorWidth; + this.reserveMinWidth(this.minItemsWidth); } else { const minimumActionWidth = this.originalPrimaryActions.length > 0 ? this.getActionMinWidth(this.originalPrimaryActions[0]) : ACTION_MIN_WIDTH + ACTION_PADDING; - this.container.style.minWidth = `${minimumActionWidth}px`; - this.element.style.minWidth = `${minimumActionWidth}px`; + this.minItemsWidth = 0; + this.reserveMinWidth(minimumActionWidth); } // Update toolbar actions to fit with container width @@ -395,6 +399,14 @@ export class ToolBar extends Disposable { return this.element.getBoundingClientRect().width; } + private reserveMinWidth(width: number): void { + if (this.options.responsiveBehavior?.reserveMinWidth === false) { + return; + } + this.container.style.minWidth = `${width}px`; + this.element.style.minWidth = `${width}px`; + } + private applyResponsiveActionMinWidths(): void { if (!this.options.responsiveBehavior?.enabled) { return; @@ -446,6 +458,12 @@ export class ToolBar extends Disposable { const parsedMinWidth = parseInt(this.element.style.minWidth); containerWidth = Math.max(containerWidth, Number.isNaN(parsedMinWidth) ? 0 : parsedMinWidth); + // Without a reserved minimum width the toolbar can be narrower than the + // width of its minimum items, in which case they overflow too. + const minItems = this.options.responsiveBehavior?.reserveMinWidth !== false || containerWidth >= this.minItemsWidth + ? this.options.responsiveBehavior?.minItems + : undefined; + // Each action is assumed to have a minimum width so that actions with a label // can shrink to the action's minimum width. We do this so that action visibility // takes precedence over the action label. @@ -585,10 +603,10 @@ export class ToolBar extends Disposable { } // Check for max items limit - if (this.options.responsiveBehavior?.minItems !== undefined) { + if (minItems !== undefined) { const primaryActionsCount = getVisiblePrimaryActionCount(); - if (primaryActionsCount <= this.options.responsiveBehavior.minItems) { + if (primaryActionsCount <= minItems) { return; } } @@ -596,8 +614,8 @@ export class ToolBar extends Disposable { // Hide actions from the configured end. while (minimumWidth > containerWidth && this.actionBar.length() > 0) { if ( - this.options.responsiveBehavior?.minItems !== undefined - && getVisiblePrimaryActionCount() <= this.options.responsiveBehavior.minItems + minItems !== undefined + && getVisiblePrimaryActionCount() <= minItems ) { break; } @@ -636,7 +654,8 @@ export class ToolBar extends Disposable { while (this.hiddenActions.length > 0) { const actionsToRestore = getActionsToRestore(); const keepToggleMenuAction = this.originalSecondaryActions.length > 0 || this.hiddenActions.length > actionsToRestore.length; - if (projectedActionBarMinimumWidth(actionsToRestore, keepToggleMenuAction) > containerWidth) { + const isBelowMinItems = this.options.responsiveBehavior?.reserveMinWidth === false && minItems !== undefined && getVisiblePrimaryActionCount() < minItems; + if (!isBelowMinItems && projectedActionBarMinimumWidth(actionsToRestore, keepToggleMenuAction) > containerWidth) { // Not enough space to show the action break; } diff --git a/src/vs/base/test/browser/ui/grid/grid.test.ts b/src/vs/base/test/browser/ui/grid/grid.test.ts index 8cd55718940e8c..d1690e8acff4ef 100644 --- a/src/vs/base/test/browser/ui/grid/grid.test.ts +++ b/src/vs/base/test/browser/ui/grid/grid.test.ts @@ -295,6 +295,39 @@ suite('Grid', function () { assert.deepStrictEqual(view4.size, [200, 600]); }); + test('branch demotion preserves hidden child visibility', function () { + const sessionsView = store.add(new TestView(50, Number.MAX_VALUE, 50, Number.MAX_VALUE)); + const grid = store.add(new Grid(sessionsView)); + container.appendChild(grid.element); + grid.layout(800, 600); + + const panelView = store.add(new TestView(50, Number.MAX_VALUE, 50, Number.MAX_VALUE)); + grid.addView(panelView, 200, sessionsView, Direction.Down); + + const editorView = store.add(new TestView(50, Number.MAX_VALUE, 50, Number.MAX_VALUE)); + grid.addView(editorView, 300, sessionsView, Direction.Right); + + const customView = store.add(new TestView(50, Number.MAX_VALUE, 50, Number.MAX_VALUE)); + grid.addView(customView, 200, editorView, Direction.Right); + grid.setViewVisible(customView, false); + + grid.moveView(panelView, 200, sessionsView, Direction.Down); + + assert.deepStrictEqual({ + sessionsVisible: grid.isViewVisible(sessionsView), + editorVisible: grid.isViewVisible(editorView), + customViewVisible: grid.isViewVisible(customView), + panelVisible: grid.isViewVisible(panelView), + customViewCachedVisibleSize: grid.getViewCachedVisibleSize(customView), + }, { + sessionsVisible: true, + editorVisible: true, + customViewVisible: false, + panelVisible: true, + customViewCachedVisibleSize: 200, + }); + }); + test('sizing should be correct after branch demotion #50675', function () { const view1 = store.add(new TestView(50, Number.MAX_VALUE, 50, Number.MAX_VALUE)); const grid = store.add(new Grid(view1)); @@ -519,6 +552,95 @@ suite('Grid', function () { assert.deepStrictEqual(view4.size, size4); }); + test('maximization preserves hidden view visibility', function () { + const view1 = store.add(new TestView(50, Number.MAX_VALUE, 50, Number.MAX_VALUE)); + const grid = store.add(new Grid(view1)); + container.appendChild(grid.element); + + grid.layout(800, 600); + + const view2 = store.add(new TestView(50, Number.MAX_VALUE, 50, Number.MAX_VALUE)); + grid.addView(view2, Sizing.Distribute, view1, Direction.Right); + + const view3 = store.add(new TestView(50, Number.MAX_VALUE, 50, Number.MAX_VALUE)); + grid.addView(view3, Sizing.Distribute, view2, Direction.Right); + + const view4 = store.add(new TestView(50, Number.MAX_VALUE, 50, Number.MAX_VALUE)); + grid.addView(view4, Sizing.Distribute, view3, Direction.Right); + + grid.setViewVisible(view3, false); + grid.setViewVisible(view4, false); + const cachedHiddenSizes = [grid.getViewCachedVisibleSize(view3), grid.getViewCachedVisibleSize(view4)]; + + grid.maximizeView(view1, [view4]); + grid.exitMaximizedView(); + + assert.deepStrictEqual({ + visibility: [view1, view2, view3, view4].map(view => grid.isViewVisible(view)), + cachedHiddenSizes: [grid.getViewCachedVisibleSize(view3), grid.getViewCachedVisibleSize(view4)], + }, { + visibility: [true, true, false, false], + cachedHiddenSizes, + }); + }); + + test('changing orientation restores maximized visibility before replacing nodes', function () { + const view1 = store.add(new TestView(50, Number.MAX_VALUE, 50, Number.MAX_VALUE)); + const grid = store.add(new Grid(view1)); + container.appendChild(grid.element); + grid.layout(800, 600); + + const view2 = store.add(new TestView(50, Number.MAX_VALUE, 50, Number.MAX_VALUE)); + grid.addView(view2, Sizing.Distribute, view1, Direction.Right); + const view3 = store.add(new TestView(50, Number.MAX_VALUE, 50, Number.MAX_VALUE)); + grid.addView(view3, Sizing.Distribute, view2, Direction.Down); + + grid.maximizeView(view1); + grid.orientation = grid.orientation === Orientation.VERTICAL ? Orientation.HORIZONTAL : Orientation.VERTICAL; + grid.exitMaximizedView(); + + assert.deepStrictEqual({ + maximized: grid.hasMaximizedView(), + visibility: [view1, view2, view3].map(view => grid.isViewVisible(view)), + }, { + maximized: false, + visibility: [true, true, true], + }); + }); + + test('maximization preserves hidden branch visibility', function () { + const deserializer = new TestViewDeserializer(store); + const grid = store.add(SerializableGrid.deserialize({ + orientation: Orientation.VERTICAL, + width: 800, + height: 600, + root: { + type: 'branch', + data: [ + { type: 'leaf', data: { name: 'view1' }, size: 400 }, + { + type: 'branch', + data: [ + { type: 'leaf', data: { name: 'view2' }, size: 300 }, + { type: 'leaf', data: { name: 'view3' }, size: 300 }, + ], + size: 400, + visible: false, + }, + ], + size: 800, + }, + }, deserializer)); + grid.layout(800, 600); + + const view1 = deserializer.getView('view1'); + const initialRoot = grid.serialize().root; + grid.maximizeView(view1); + grid.exitMaximizedView(); + + assert.deepStrictEqual(grid.serialize().root, initialRoot); + }); + test('hasMaximizedView', function () { const view1 = store.add(new TestView(50, Number.MAX_VALUE, 50, Number.MAX_VALUE)); const grid = store.add(new Grid(view1)); @@ -593,7 +715,9 @@ suite('Grid', function () { assert.deepStrictEqual(grid.isViewVisible(view2), true); assert.deepStrictEqual(grid.isViewVisible(view3), true); - // Changing the visibility of any view while a view is maximized, unmaximizes the view + // Revealing a hidden view while a view is maximized both restores the grid + // and applies the requested visibility. + grid.setViewVisible(view3, false); grid.maximizeView(view1); assert.deepStrictEqual(grid.hasMaximizedView(), true); grid.setViewVisible(view3, true); diff --git a/src/vs/base/test/browser/ui/list/listView.test.ts b/src/vs/base/test/browser/ui/list/listView.test.ts index 4d5dc86be9b436..29076ef89835c4 100644 --- a/src/vs/base/test/browser/ui/list/listView.test.ts +++ b/src/vs/base/test/browser/ui/list/listView.test.ts @@ -206,6 +206,112 @@ suite('ListView', function () { } }); + test('preserves existing rows while measuring overestimated appended rows', function () { + const container = document.createElement('div'); + container.style.height = '300px'; + container.style.width = '200px'; + document.body.appendChild(container); + + type TestElement = { height: number }; + type Template = { container: HTMLElement; element?: TestElement }; + const delegate = new class extends CachedListVirtualDelegate { + protected estimateHeight() { return 200; } + getTemplateId() { return 'template'; } + hasDynamicHeight() { return true; } + }; + const renderer: IListRenderer = { + templateId: 'template', + renderTemplate(container) { return { container }; }, + renderElement(element, _index, template) { + if (template.element !== element) { + template.element = element; + const content = document.createElement('div'); + content.style.height = `${element.height}px`; + template.container.replaceChildren(content); + } + }, + disposeTemplate() { } + }; + const listView = new ListView(container, delegate, [renderer], { supportDynamicHeights: true }); + try { + listView.layout(300, 200); + listView.splice(0, 0, [{ height: 400 }]); + listView.scrollTop = listView.scrollHeight; + const originalContent = listView.domElement(0)?.firstElementChild; + assert.ok(originalContent); + + listView.splice(1, 0, [{ height: 30 }, { height: 20 }]); + listView.scrollTop = listView.scrollHeight; + + assert.deepStrictEqual({ + sameContent: listView.domElement(0)?.firstElementChild === originalContent, + connected: originalContent.isConnected, + contentHeight: listView.contentHeight, + scrollTop: listView.scrollTop, + }, { + sameContent: true, + connected: true, + contentHeight: 450, + scrollTop: 150, + }); + } finally { + listView.dispose(); + container.remove(); + } + }); + + test('reuses a bounded row pool while measuring appended rows', function () { + const container = document.createElement('div'); + document.body.appendChild(container); + + type TestElement = { height: number }; + const delegate = new class extends CachedListVirtualDelegate { + protected estimateHeight() { return 200; } + getTemplateId() { return 'template'; } + hasDynamicHeight() { return true; } + }; + let templatesCount = 0; + const renderer: IListRenderer = { + templateId: 'template', + renderTemplate(container) { + templatesCount++; + const content = document.createElement('div'); + container.appendChild(content); + return content; + }, + renderElement(element, _index, content) { + content.style.height = `${element.height}px`; + }, + disposeTemplate() { templatesCount--; } + }; + const listView = new ListView(container, delegate, [renderer], { supportDynamicHeights: true }); + let warmedTemplatesCount = 0; + let finalTemplatesCount = 0; + try { + listView.layout(300, 200); + for (let batch = 0; batch < 100; batch++) { + listView.splice(listView.length, 0, [{ height: 30 }, { height: 20 }]); + listView.scrollTop = listView.scrollHeight; + if (batch === 49) { + warmedTemplatesCount = templatesCount; + } + } + finalTemplatesCount = templatesCount; + } finally { + listView.dispose(); + container.remove(); + } + assert.deepStrictEqual({ + allocatedTemplates: warmedTemplatesCount > 0, + templatesAfterMoreRows: finalTemplatesCount, + templatesAfterDisposal: templatesCount, + }, { + allocatedTemplates: true, + templatesAfterMoreRows: warmedTemplatesCount, + templatesAfterDisposal: 0, + }); + }); + test('cleans up retained dynamic height rows after a render error', function () { const element = document.createElement('div'); element.style.height = '100px'; diff --git a/src/vs/base/test/browser/ui/toolbar/toolbar.test.ts b/src/vs/base/test/browser/ui/toolbar/toolbar.test.ts index ed26f69928a2e6..94e9f47c62187f 100644 --- a/src/vs/base/test/browser/ui/toolbar/toolbar.test.ts +++ b/src/vs/base/test/browser/ui/toolbar/toolbar.test.ts @@ -554,6 +554,47 @@ suite('ToolBar', () => { ]); }); + test('overflows the minimum actions when their width is not reserved and does not fit', () => { + let availableWidth = 300; + const toolbar = store.add(new TestToolBar(container, contextMenuProvider, { + responsiveBehavior: { + enabled: true, + kind: 'last', + minItems: 2, + actionMinWidth: 22, + reserveMinWidth: false, + getAvailableWidth: () => availableWidth, + }, + actionViewItemProvider: action => new FixedWidthActionViewItem(action, 60), + })); + toolbar.setActions([ + store.add(new Action('attach', 'Attach')), + store.add(new Action('agent', 'Agent')), + store.add(new Action('model', 'Model')), + store.add(new Action('settings', 'Settings')), + ]); + + const states: string[][] = []; + for (const width of [100, 40, 100, 300]) { + availableWidth = width; + toolbar.relayout(); + states.push(Array.from({ length: toolbar.getItemsLength() }, (_, index) => toolbar.getItemAction(index)?.id ?? '')); + } + + assert.deepStrictEqual({ + states, + minWidths: [container.style.minWidth, toolbar.getElement().style.minWidth], + }, { + states: [ + ['attach', 'agent', ToggleMenuAction.ID], + [ToggleMenuAction.ID], + ['attach', 'agent', ToggleMenuAction.ID], + ['attach', 'agent', 'model', 'settings'], + ], + minWidths: ['', ''], + }); + }); + test('uses overflow-specific proxy actions', async () => { const runs: string[] = []; let overflowAnchor: HTMLElement | undefined; diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index 8d80690ab52b84..0161ee0bfeb4fd 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -2030,6 +2030,16 @@ export class ActionListWidget extends Disposable { this._hideSubmenu(); } + /** Update an open searchable list without closing its owning picker. */ + setFilter(value: string, focusItemId?: string): void { + if (!this._filterInput) { return; } + this._filterInput.value = value; + this._filterText = value; + this._applyOrUpdateFilter(); + if (focusItemId) { this.focusItemById(focusItemId); } + this._filterInput.focus(); + } + clearFilter(): boolean { if (this._filterInput && this._filterText) { this._filterInput.value = ''; @@ -3440,6 +3450,10 @@ export class ActionList extends Disposable { } } + setFilter(value: string, focusItemId?: string): void { + this._widget.setFilter(value, focusItemId); + } + clearFilter(): boolean { return this._widget.clearFilter(); } diff --git a/src/vs/platform/actionWidget/browser/actionWidget.ts b/src/vs/platform/actionWidget/browser/actionWidget.ts index 6a7746a9da7d64..2999e61b011b30 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.ts +++ b/src/vs/platform/actionWidget/browser/actionWidget.ts @@ -61,6 +61,7 @@ export interface IActionWidgetService { * rebuilding the list. */ focusItemById(itemId: string): void; + setFilter(value: string, focusItemId?: string): void; hide(didCancel?: boolean): void; @@ -145,6 +146,10 @@ export class ActionWidgetService extends Disposable implements IActionWidgetServ return this._list?.value?.toggleFocusedSection() ?? false; } + setFilter(value: string, focusItemId?: string): void { + this._list.value?.setFilter(value, focusItemId); + } + clearFilter(): boolean { return this._list?.value?.clearFilter() ?? false; } diff --git a/src/vs/platform/actionWidget/test/browser/actionList.test.ts b/src/vs/platform/actionWidget/test/browser/actionList.test.ts index 468fb3b8015c6a..5c65e6668329be 100644 --- a/src/vs/platform/actionWidget/test/browser/actionList.test.ts +++ b/src/vs/platform/actionWidget/test/browser/actionList.test.ts @@ -3221,6 +3221,16 @@ suite('ActionListWidget', () => { ); }); + test('updates an open search and focuses the exact duplicate-label row without selecting', () => { + const widget = createActionListWidget(disposables, { + items: [{ ...action('source'), label: 'GPT' }, { ...action('target'), label: 'GPT' }, action('different')], + listOptions: { showFilter: true, filterAsCombobox: true }, + }); + widget.setFilter('GPT', 'target'); + widget.setFilter('GPT', 'target'); + assert.deepStrictEqual({ query: widget.filterInput?.value, focused: widget.getFocusedElement()?.item?.id }, { query: 'GPT', focused: 'target' }); + }); + test('focuses the configured initial item when opened', () => { const widget = createActionListWidget(disposables, { items: [action('first'), action('active'), action('last')], diff --git a/src/vs/platform/agentHost/AGENTS.md b/src/vs/platform/agentHost/AGENTS.md index 0788eb4b5157f2..918f899a43a8af 100644 --- a/src/vs/platform/agentHost/AGENTS.md +++ b/src/vs/platform/agentHost/AGENTS.md @@ -86,6 +86,7 @@ Responsible for: - Creating and owning SDK chats (`chats.createChat`, with optional fork input). - Reading history (`chats.getMessages`). - Emitting progress signals (`onDidChatProgress`). +- Optionally admitting provider-native metadata through `onDidChatSessionEvent` for environment-owned SDK mirroring. History reads receive exact chat/context plus an acknowledged journal cursor; they never create or resume a runtime. Only the owning provider interprets native records or synchronizes a host title into a live backing. - Emitting membership events for harness-spawned chats (`onDidSpawnChat`, `onDidEndChat`). - Re-attaching a chat's backing on restore (`materializeChat`) — including the session's default chat. - Advertising static capability flags (`getDescriptor().capabilities`). @@ -94,6 +95,14 @@ Agents do **not** maintain the chat catalog, persist membership, know whether a **File organization rule:** `common/agent.ts` holds the *provider model* — `IAgent` and every type/helper/signal reachable from it (chat lifecycle, create/materialize/legacy-migration payloads, config-resolution parameters, `AgentSignal`/`AgentSession`). `common/agentService.ts` holds the *orchestrator-facing service surface* — `IAgentService`, `IAgentConnection`, `IAgentHostService`, settings/env constants, and diagnostics types. The dependency is one-directional: `agentService.ts` may import from `agent.ts`, but `agent.ts` must never import from `agentService.ts`. `agentService.ts` re-exports the public provider types from `agent.ts` for call-site compatibility; new provider code should import directly from `agent.ts`. +### Image-generation tools + +Copilot's runtime-owned `image_generation` tool maps ordinary SDK start, progress, and completion events into existing AHP tool-call actions. Codex's `imageGeneration` items map to `image_gen.imagegen` and use the same renderer. The final revised Codex prompt is published before completion; unavailable prompts remain absent in both live and restored results. + +Copilot's optional `structuredContent.imageGeneration.requestedModel` maps to `_meta["vscode.imageGeneration"]`, validated by [agentImageGenerationMeta.ts](common/meta/agentImageGenerationMeta.ts). This identifies the requested image engine, not the conversation model or a confirmed serving model. Hosts without this metadata retain generic image labels. + +Availability, authorization, and durable tool history remain provider-owned. Inline images use AHP embedded-resource content; resource links must resolve through the host's `resourceRead`. An opaque SDK asset ID alone is not a readable AHP resource. + ### Orchestrator layer When `chat.experimental.workspaceSnapshot` is enabled (root config `workspaceSnapshotEnabled`; off by default and experiment-controlled), Copilot chats receive a bounded initial workspace-file snapshot through `WorkspaceContextContribution` on the first turn of a new conversation: the default chat or a user-created peer chat whose chat state is loaded and has no turns, never a restored chat (even one whose history is not loaded yet, such as a `send_message` target), fork, side chat, or tool-spawned chat. It uses the working directories resolved for the turn (`IOutgoingTurn.workingDirectories`, including a worktree created on the first send, which session state does not reflect until the provider materializes). Like the classic Copilot Chat workspace structure, it walks directories breadth first through `IFileService`, reading only the directories whose names fit the 2,000-character budget and keeping no more entries of a directory than the budget could list; it skips hidden entries, a short list of dependency and build folders, and Git storage directories, does not apply `.gitignore`, and reads no file contents (its Git-storage check reads only a linked worktree's `commondir` pointer, capped at 4 KB). The contribution returns the listing as an ordinary host instruction (a `` block), so no provider changes are involved. The listing is not checked against content exclusion: it holds file names only, never contents. Preparation starts when the first turn's `ChatTurnStarted` is dispatched (`onDidDispatchAction`, so client and host-started turns such as `send_message`, `create_session`, and automations both overlap it with the rest of the send path), or, for a session creating its worktree, as soon as the worktree exists (`onDidChangeWorkingDirectoryPending`); the send waits at most one second for unfinished roots and omits the rest. The snapshot counts as sent when the host hands the turn to the provider (`AgentHostTurnTracker.onDidDispatchTurn`, after the final pre-dispatch cancellation checks). Only then is it logged at info level and reported as `agentHost.workspaceSnapshot` telemetry (roots included, pending, empty, or unreadable, and the snapshot's length). A turn that fails after that point is not retried with the snapshot. A first turn that never reaches the provider, such as a local command or a turn cancelled before dispatch, leaves the snapshot for the next turn that does. Only the chat's active turn may use or clean up snapshot state, so a cancelled send that is still unwinding cannot affect the next turn. A first turn's end is observed from its terminal state action (`ChatTurnComplete`, `ChatTurnCancelled`, or `ChatError`) through `onDidDispatchAction` and matched by turn id, not from `onTurnEnd`: a cancelled local command's turn end arrives only when the command finishes, possibly after a replacement turn started. Preparation stops once the send has run, or when the turn ends, the chat is removed (`SessionChatRemoved`), or the session is removed (`onDidRemoveSession`). @@ -112,7 +121,7 @@ Artifact tools read and mutate only the invoking chat's artifacts. The session d **`AgentService` (`node/agentService.ts`):** - Resolves the `(session, chat)` → `(agent, session URI, chat URI)` mapping for orchestration. -- Uses `IAgentHostProviderService` for provider ownership and session routing. Its `getProviderForSession` path falls back through the session URI's scheme when a restored session was not associated in this process lifetime. +- Uses `IAgentHostProviderService` for provider ownership and session routing. Registry provider associations route standard resources; scheme fallback is bounded to legacy provider-native resources. - Owns `AgentSessionRegistry`, backed by `sessions_v2` in `agent-host.db`, as the durable source of truth for which sessions exist. `listSessions` reads validated, rebuildable central payloads and applies live-state overlays; provider/local database reads are fallback and reconciliation paths, not prerequisites for listing. - Dispatches user-driven chat lifecycle (`createChat`, `disposeChat`) to `chats.*`. - Disposes every catalog chat in stable order (peers first, initial chat last); releases every catalog chat on idle eviction. @@ -166,7 +175,7 @@ Artifact tools read and mutate only the invoking chat's artifacts. The session d Opaque to the host, but not arbitrary for the provider: whatever id the blob carries is the *only* handle the provider gets back on the next process, so it must name the provider's own durable runtime — the key that runtime is registered and addressed under — and not a transient SDK handle that the provider decouples from it. Codex's session-backing chat is the worked example: its runtime keeps the host-minted session id and records its app-server thread id in a metadata overlay, so a thread-keyed blob would restore the runtime under an id nothing addresses it by (leaving every notification unroutable) and would go stale the moment a rematerialization mints a new thread. Where the two genuinely coincide — a Codex peer chat or fork, whose runtime *is* its thread — recording the thread id is the same thing as recording the runtime id. `IAgentCreateChatResult.backingSession` remains the place to name a separately enumerable SDK conversation (I7); it is not a second id channel for the blob. -**I2 — `sessionUri` and `chatChannelUri` are never overloaded.** A session URI (`ahp-copilot://`, `ahp-claude://`, …) identifies a session. A chat channel URI (`ahp-chat://…`) identifies a chat within a session. The two schemes are structurally distinct; `isAhpChatChannel` / `parseDefaultChatUri` / `buildDefaultChatUri` are the only crossing points. Passing a chat URI where a session URI is expected (or vice versa) is a bug. +**I2 — `sessionUri` and `chatChannelUri` are never overloaded.** A native session URI (`ahp-session:/` or a preserved legacy provider URI) identifies an AH session; `ahp-chat://…` identifies its chat. Native chat helpers derive chat resources from the actual immutable session URI. AHP clients use third-party hosts' advertised session and chat resources verbatim rather than applying native derivation. Passing a chat URI where a session URI is expected (or vice versa) is a bug. **I3 — The default chat uses the same explicit backing contract as every chat.** The default chat URI is derived from the AH session URI, but its provider identity is opaque `providerData`. Claude and Copilot mint independent SDK ids, return them from `createChat`, and restore them through `materializeChat`; equality with the AH session id is never assumed and there is no identity-reuse bind fallback. Codex persists its explicit thread mapping. AH never depends on provider identity reuse for ownership or enumeration. @@ -188,7 +197,9 @@ Catalog imports may revisit registered sessions to repair payloads, but only new Because listing never opens session storage, migrations the provider/session fallback would perform during a list run in the catalog's maintenance pass instead, where the database is already open. Legacy per-kind changeset blobs are aggregated and written to the modern changes summary there, so the chip stays correct without adding a per-session read to the list path. -**I6 — Route through `IAgentHostProviderService`.** The provider service's explicit session association is populated only by `AgentService.createSession`. A restored session (alive in the state manager after a host restart but never created in this process) is absent from it, so restore re-associates the durable `AgentSessionRegistry` provider before lookup. For unregistered provider-native sessions, `getProviderForSession` falls back to the session URI scheme. Do not read the association map directly. +**I6 — Route through `IAgentHostProviderService`.** Creation, registry listing and restoration populate explicit provider associations from durable identity. Only unregistered legacy provider-native resources may fall back to their scheme; `ahp-session` never identifies a provider. Do not read the association map directly. + +**Native identities are mixed and immutable.** Negotiate `vscode.ahpSessionUris` only for new allocations; preserve existing registry URIs, SDK backings, storage directories and frontend identities. Use explicit provider metadata and owning-connection address records, not a provider-wide URI alias. See the [session identity contract](../../sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md#identity). **I7 — A peer chat's backing SDK session must never surface as a top-level session.** Some agents store all SDK conversations in one catalog. `IAgentCreateChatResult.backingSession` lets the orchestrator mark any internal chat backing, including the default Claude backing, so continual external-chat discovery never registers it as a top-level AH session. Providers own native enumeration and push candidates through `onDidDiscoverChats`; Agent Host reconciles those candidates against its registry and suppresses separately enumerable internal backings. Existing AH-created rows retain their provenance. Marking a backing session is a durable metadata write on the backing session's own DB (`_markChatBacking`); a transient failure is retried once, and if it keeps failing the session is suppressed from listing/discovery in-process (`_unpersistedChatBackings`) rather than failing the chat creation that triggered it. @@ -202,7 +213,7 @@ Because listing never opens session storage, migrations the provider/session fal `AgentSessionRegistry.list()` reads the registry once and passes every entry through the migration callback supplied by Agent Service. The callback returns a replacement only for legacy entries whose `external` column is `NULL`, resolving them through the `agentHost.workspaceless` classifier. The registry persists all replacements in one transaction and returns the computed list without rereading the database. Migration uses bounded concurrency. Explicit internal registration sources are preserved; externally classified rows become discovery entries. -`register` takes the resolved provenance and whether to check tombstones. Explicit `AgentService.createSession` calls skip the tombstone check and clear any tombstone for that session URI; restore and discovery calls atomically decline to register if the session is or concurrently becomes tombstoned. An explicit row is never rewritten by catalog discovery. A migration-time host-owned marker can correct a previously discovered row back to internal provenance. +`register` takes the resolved provenance and whether to check tombstones. Explicit creation retains the legacy registry behavior of clearing a tombstone, but newly allocated standard resources must first pass the host's raw-storage-ID and tombstone collision checks. Restore and discovery calls atomically decline to register if the session is or concurrently becomes tombstoned. An explicit row is never rewritten by catalog discovery. A migration-time host-owned marker can correct a previously discovered row back to internal provenance. Providers own discovery lifecycle and push unknown chats with provider-classified provenance through `onDidDiscoverChats`. Claude, Codex, and Copilot classify their unknown native chats as external, except that Copilot keeps an unknown *legacy extension-host* chat internal because it is adoptable in place rather than someone else's session. Agent Service preserves that classification unless the existing host-owned `agentHost.workspaceless` marker proves the session was created by Agent Host; it reads that evidence together with the chat-backing marker before additively registering the event payload. Agent Service always attaches the event listener and queues each provider's external-session discovery through `_runWhenStartupSettled`, so the request waits for both Agent Host startup and the first successful session listing. Providers registered after that barrier opens run their queued work immediately, and a later transition from `none` starts discovery directly. Adopt-in-place legacy migration remains an independent provider-initialization trigger immediately after the discovery listener is attached, and another catalog consumer may also trigger discovery after it enumerates the provider catalog. This keeps `showExternalSessions: none` from initiating native discovery while allowing independently triggered discovery to populate the hidden registry normally. Ordinary list refreshes never enumerate provider catalogs. External discovery has no migration marker or Copilot migrate-legacy gate; only the adoptable legacy extension-host half of Copilot's payload is withheld while migrate-legacy is off. Discovery never prunes a registry row when a provider later omits it and filters subagents and marked internal chat backings. @@ -228,9 +239,11 @@ Migration returns known native entries as plain metadata. Discovery classifies u ### Automatic titles -Deferred naming is the default host scheduling strategy for new sessions. There is no title-generation setting or root-config gate. +`chat.agentHost.experimental.deferredTitleGeneration` (host root key `deferredTitleGeneration`) is an opt-in, default-off host scheduling experiment. It takes precedence over `chat.agentHost.experimental.activeAgentTitleGeneration`. With deferred naming off, the active-agent setting keeps the legacy choice between foreground `rename_chat` naming and immediate utility-model naming; its workbench default is enabled outside Stable, while the standalone root schema defaults to disabled. + +The workbench setting opts into automatic experiment overrides via `experiment: { mode: 'auto' }`, using treatment name `config.chat.agentHost.experimental.deferredTitleGeneration`. The effective setting is synced to the host root key; the `experimental` tag alone does not enable experiment overrides. -The title controller snapshots `titleGenerationStrategy` on the first session-scoped lookup, including provider creation before state registration, and persists it once session state exists. Failed creation clears the snapshot. All its chats, including peers added later, share that strategy. Restore hydrates a persisted strategy before the provider materializes its tool inventory, so existing `activeAgent`, `utility`, and `deferred` sessions retain their behavior. Older sessions without this metadata remain on utility naming, while existing materialized legacy sessions use their advertised rename-tool membership as a compatibility fallback only. +The title controller snapshots `titleGenerationStrategy` on the first session-scoped lookup, including provider creation before state registration, and persists it once session state exists. Failed creation clears the snapshot. All its chats, including peers added later, share that strategy; root changes affect new sessions only. Restore hydrates the strategy before the provider materializes its tool inventory. Older sessions without this metadata remain on utility naming, never implicitly opting into deferred naming. Existing materialized legacy sessions use their advertised rename-tool membership as a compatibility fallback only. Deferred mode synchronously publishes and starts persisting an automatic fallback title, without a utility request, GitHub enrichment, foreground rename reminder, or automatic-naming tool guidance. The existing `SessionTitleContribution` starts at most one non-awaited utility refinement after the first new successful response with nonempty markdown. Forks wait for their first new response rather than titling the inherited history during creation; locally handled commands do not consume this opportunity. A separate `deferredTitleSeed` record preserves the seed and first-response index across restart. Hydration restores eligibility only when that record still matches the persisted title and automatic provenance; it never generates a title itself. Terminal outcomes consume eligibility before any utility call, so completed, failed, cancelled, or empty first turns are not retried after restart. Cancellation, errors, empty responses, unavailable utility credentials, and disposal retain the fallback. diff --git a/src/vs/platform/agentHost/OTEL.md b/src/vs/platform/agentHost/OTEL.md index af8e5c71056d5c..db69eb88d68bda 100644 --- a/src/vs/platform/agentHost/OTEL.md +++ b/src/vs/platform/agentHost/OTEL.md @@ -8,6 +8,10 @@ Product startup telemetry and its host-lifetime correlation ID are documented in [`PERFORMANCE.md`](PERFORMANCE.md). They use the existing usage-telemetry consent, not the OTel exporter or its configuration. +Host-wide diagnostic logs can be subscribed to over local Agent Host ingress, but are not advertised on Mission Control relay connections. Those logs must not contend with session operations on the bounded relay publisher. This is separate from provider-native OTel logs and their configured exporter destinations. + + + | Property | Agent Host OTel | Extension OTel | |---|---|---| | Process | Separate utility process (`src/vs/platform/agentHost/node/`) | Extension host | diff --git a/src/vs/platform/agentHost/browser/agentHostConnectionsService.ts b/src/vs/platform/agentHost/browser/agentHostConnectionsService.ts index 021361c505795d..c49ea5c49ffec1 100644 --- a/src/vs/platform/agentHost/browser/agentHostConnectionsService.ts +++ b/src/vs/platform/agentHost/browser/agentHostConnectionsService.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Emitter, Event } from '../../../base/common/event.js'; -import { Disposable, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { Schemas } from '../../../base/common/network.js'; import { OperatingSystem } from '../../../base/common/platform.js'; import { URI } from '../../../base/common/uri.js'; @@ -19,6 +19,8 @@ import { findRemoteAgentHostSessionTypeAuthority, isRemoteAgentHostSessionType, import { AGENT_HOST_SCHEME, agentHostAuthority } from '../common/agentHostUri.js'; import { IRemoteAgentHostService } from '../common/remoteAgentHostService.js'; import { getAgentHostOperatingSystem } from '../common/agentHostOperatingSystem.js'; +import { ResourceMap } from '../../../base/common/map.js'; +import { isEqual } from '../../../base/common/resources.js'; /** * Default {@link IAgentHostConnectionsService} that composes the ambient @@ -34,6 +36,8 @@ export class AgentHostConnectionsService extends Disposable implements IAgentHos private readonly _onDidChangeSessionResolution = this._register(new Emitter()); readonly onDidChangeSessionResolution: Event = this._onDidChangeSessionResolution.event; private readonly _sessionResolutionPolicies = new Map(); + private readonly _backendSessions = new ResourceMap<{ readonly backend: URI; readonly authority: string; readonly provider: string }>(); + private readonly _connectionListeners = this._register(new DisposableMap()); constructor( @IAgentHostService private readonly _agentHostService: IAgentHostService, @@ -50,13 +54,42 @@ export class AgentHostConnectionsService extends Disposable implements IAgentHos // Ambient (re)start/exit changes whether the ambient connection is ready. this._register(this._agentHostService.onAgentHostStart(() => this._fireConnectionsChanged())); this._register(this._agentHostService.onAgentHostExit(() => this._fireConnectionsChanged())); + this._watchConnections(); } private _fireConnectionsChanged(): void { + this._watchConnections(); this._onDidChangeConnections.fire(); this._onDidChangeSessionResolution.fire(); } + private _watchConnections(): void { + const connections = new Set(); + for (const info of this.connections) { + const connection = info.connection; + if (!connection) { + continue; + } + connections.add(connection); + if (!this._connectionListeners.has(connection)) { + const store = new DisposableStore(); + this._connectionListeners.set(connection, store); + if (connection.onDidNotification) { + store.add(connection.onDidNotification(notification => { + if (notification.type === 'root/sessionAdded') { + this.registerSessionResource(URI.parse(notification.summary.resource), info.authority, notification.summary.provider); + } + })); + } + } + } + for (const connection of this._connectionListeners.keys()) { + if (!connections.has(connection)) { + this._connectionListeners.deleteAndDispose(connection); + } + } + } + get ambientConnection(): IAgentConnection { return this._agentHostService; } @@ -141,14 +174,68 @@ export class AgentHostConnectionsService extends Disposable implements IAgentHos return connection ? { ...identity, connection } : undefined; } + registerSessionResource(backendSession: URI, authority = AMBIENT_AGENT_HOST_AUTHORITY, advertisedProvider?: string): URI { + const prefix = authority === AMBIENT_AGENT_HOST_AUTHORITY ? LOCAL_AGENT_HOST_SCHEME_PREFIX : remoteAgentHostSessionTypeAuthorityPrefix(authority); + if (!advertisedProvider) { + const known = this.findSessionResource(backendSession, authority); + if (known) { + return known; + } + } + const backend = advertisedProvider ?? AgentSession.provider(backendSession); + const alias = this._sessionResolutionPolicies.get(authority)?.sessionSchemeAlias; + const provider = advertisedProvider ?? (alias && alias.backend === backendSession.scheme ? alias.ui : backend); + if (!provider) { + throw new Error(`Session has no advertised provider: ${backendSession.toString()}`); + } + const resource = backendSession.with({ scheme: `${prefix}${provider}` }); + const previous = this._backendSessions.get(resource.with({ fragment: '' })); + if (previous && !isEqual(previous.backend, backendSession)) { + throw new Error(`Conflicting backend identities for ${resource.toString()}`); + } + this._backendSessions.set(resource.with({ fragment: '' }), { backend: backendSession, authority, provider }); + if (!previous) { + this._onDidChangeSessionResolution.fire(); + } + return resource; + } + + findSessionResource(backendSession: URI, authority = AMBIENT_AGENT_HOST_AUTHORITY): URI | undefined { + return [...this._backendSessions].find(([, identity]) => identity.authority === authority && isEqual(identity.backend, backendSession))?.[0]; + } + + getSessionResource(backendSession: URI, authority = AMBIENT_AGENT_HOST_AUTHORITY): URI | undefined { + const known = this.findSessionResource(backendSession, authority); + if (known) { + return known; + } + if (backendSession.authority || backendSession.query || backendSession.fragment) { + return undefined; + } + const backend = AgentSession.provider(backendSession); + const alias = this._sessionResolutionPolicies.get(authority)?.sessionSchemeAlias; + const provider = alias && alias.backend === backendSession.scheme ? alias.ui : backend; + if (!provider) { + return undefined; + } + const prefix = authority === AMBIENT_AGENT_HOST_AUTHORITY ? LOCAL_AGENT_HOST_SCHEME_PREFIX : remoteAgentHostSessionTypeAuthorityPrefix(authority); + const resource = backendSession.with({ scheme: `${prefix}${provider}` }); + // UI fragments select peer chats. Keep the existing owner-resolution + // contract and decline addresses it cannot represent without identity loss. + return isEqual(this.resolveSessionResourceIdentity(resource)?.backendSession, backendSession) ? resource : undefined; + } + resolveSessionResourceIdentity(sessionResource: URI): IAgentHostSessionIdentity | undefined { + const known = this._backendSessions.get(sessionResource.with({ fragment: '' })); + if (known) { + return this._createSessionIdentity(known.authority, known.provider, sessionResource); + } const scheme = sessionResource.scheme; - const rawSessionId = sessionResource.path.substring(1); if (scheme.startsWith(LOCAL_AGENT_HOST_SCHEME_PREFIX)) { const provider = scheme.substring(LOCAL_AGENT_HOST_SCHEME_PREFIX.length); return provider - ? this._createSessionIdentity(AMBIENT_AGENT_HOST_AUTHORITY, provider, rawSessionId) + ? this._createSessionIdentity(AMBIENT_AGENT_HOST_AUTHORITY, provider, sessionResource) : undefined; } @@ -164,7 +251,7 @@ export class AgentHostConnectionsService extends Disposable implements IAgentHos if (authority) { const provider = scheme.substring(remoteAgentHostSessionTypeAuthorityPrefix(authority).length); if (provider) { - return this._createSessionIdentity(authority, provider, rawSessionId); + return this._createSessionIdentity(authority, provider, sessionResource); } } } @@ -172,14 +259,16 @@ export class AgentHostConnectionsService extends Disposable implements IAgentHos return undefined; } - private _createSessionIdentity(authority: string, provider: string, rawSessionId: string): IAgentHostSessionIdentity { + private _createSessionIdentity(authority: string, provider: string, sessionResource: URI): IAgentHostSessionIdentity { const policy = this._sessionResolutionPolicies.get(authority); const alias = policy?.sessionSchemeAlias; const backendProvider = alias?.ui === provider ? alias.backend : provider; return { connectionAuthority: authority, ...(policy?.connectionAddress !== undefined ? { connectionAddress: policy.connectionAddress } : {}), - backendSession: AgentSession.uri(backendProvider, rawSessionId), + backendSession: this._backendSessions.get(sessionResource.with({ fragment: '' }))?.backend + ?? sessionResource.with({ scheme: backendProvider, fragment: '' }), + ...(this._backendSessions.has(sessionResource.with({ fragment: '' })) ? { backendSessionIsAdvertised: true as const } : {}), defaultChangesetKind: policy?.defaultChangesetKind, }; } diff --git a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts index 508e63c06e24b0..b181d7f1157208 100644 --- a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts @@ -5,7 +5,7 @@ // Protocol client for communicating with an agent host process. -import { DeferredPromise, TimeoutTimer } from '../../../base/common/async.js'; +import { DeferredPromise, disposableTimeout, IntervalTimer, TimeoutTimer } from '../../../base/common/async.js'; import { CancellationError } from '../../../base/common/errors.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, DisposableStore, MutableDisposable, IReference, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; @@ -13,16 +13,20 @@ import { Schemas } from '../../../base/common/network.js'; import { hasKey } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; +import { newAgentHostSessionUri } from '../common/agentHostSessionIdentity.js'; +import { AgentHostSessionUrisCapabilityMetaKey } from '../common/meta/agentHostSessionUrisMeta.js'; +import { readRelayKeepAliveTimeout } from '../common/meta/relayConnectionMeta.js'; import { vArray, vEnum, vObj, vOptionalProp, vString } from '../../../base/common/validation.js'; import { localize } from '../../../nls.js'; import { ILogService } from '../../log/common/log.js'; import { FileSystemProviderErrorCode, toFileSystemProviderErrorCode } from '../../files/common/files.js'; import { ConfigurationTarget, ConfigurationTargetToString, IConfigurationService } from '../../configuration/common/configuration.js'; -import { AgentSession, IAgentCreateChatRequestOptions, IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IMcpNotification } from '../common/agent.js'; +import { IAgentCreateChatRequestOptions, IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IMcpNotification } from '../common/agent.js'; import { AGENT_HOST_DEBUG_LOGS_CHUNK_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES, IAgentConnection, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk } from '../common/agentService.js'; import { ClaimAgentHostDetachedWorktreeExtensionMethod, CollectAgentHostDebugLogsExtensionMethod, CreateAgentHostDetachedWorktreeExtensionMethod, DeleteAgentHostDetachedWorktreeExtensionMethod, GetAgentHostSessionStateFileExtensionMethod, ImportSessionExtensionMethod, ReadAgentHostDebugLogsChunkExtensionMethod, ReconcileAgentHostDetachedWorktreesExtensionMethod, RemoveSessionArtifactExtensionMethod, ReportAgentHostFirstResponseExtensionMethod, ReportChatUserInteractionExtensionMethod, RequestAgentHostMcpAuthenticationExtensionMethod, RequestAgentHostWorkspaceTrustExtensionMethod, SetAgentHostDetachedWorktreeArchivedExtensionMethod, supportsAgentHostChatStateFile, supportsAgentHostDevContainers, type IAgentHostExtensionCommandMap, type IAgentHostExtensionInitializeResult, type IAgentHostExtensionServerCommandMap, type IAgentHostMcpAuthenticationRequest } from '../common/agentHostExtensionProtocol.js'; import { McpAuthRequiredReason } from '../common/state/protocol/channels-session/state.js'; import { supportsAgentHostTiming, supportsChatUserInteractionTiming } from '../common/meta/agentHostTimingMeta.js'; +import { readCodexSessionModel } from '../common/meta/codexSessionModel.js'; import type { IAgentHostFirstResponseDiagnostic } from '../common/otel/agentHostTiming.js'; import type { IChatUserInteractionTiming } from '../../otel/common/chatUserInteraction.js'; import { AMBIENT_AGENT_HOST_AUTHORITY } from '../common/agentHostConnectionsService.js'; @@ -35,7 +39,7 @@ import { ActionType, type ActionEnvelope, type ChatAction, type ClientAnnotation import { MessageAttachmentKind, SessionSummary, ROOT_STATE_URI, StateComponents, isAhpRootChannel, isDefaultChatUri, isSessionStatusArchived, isSessionStatusRead, type ClientPluginCustomization, type Message, type RootState } from '../common/state/sessionState.js'; import { normalizeLegacyActionEnvelope } from '../common/state/legacyProtocolCompatibility.js'; import { SUPPORTED_PROTOCOL_VERSIONS } from '../common/state/protocol/version/registry.js'; -import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, ProtocolError, ReconnectResultType, type ProtocolMessage, type IStateSnapshot } from '../common/state/sessionProtocol.js'; +import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, ProtocolError, ReconnectResultType, JSON_RPC_INTERNAL_ERROR, type ProtocolMessage, type IStateSnapshot, type SubscribeResult } from '../common/state/sessionProtocol.js'; import { type IVscodeUpgradeResult } from '../common/state/protocolUpgrade.js'; import { isClientTransport, NonReconnectableTransportError, type AgentHostTransportFailureReason, type IProtocolTransport } from '../common/state/sessionTransport.js'; import { AhpErrorCodes, JsonRpcErrorCodes } from '../common/state/protocol/errors.js'; @@ -254,6 +258,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect private readonly _resourceIdentity: AgentHostResourceIdentity; private readonly _transportFactory: (() => IProtocolTransport) | undefined; private _transport!: IProtocolTransport; + private _relayRootRefresh: { readonly transport: IProtocolTransport; readonly promise: Promise } | undefined; /** Disposable holding the listeners attached to the current transport. */ private readonly _transportListeners = this._register(new MutableDisposable()); private readonly _connectionAuthority: string; @@ -385,6 +390,8 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect * their own. */ private readonly _pingTimer = this._register(new TimeoutTimer()); + private readonly _relayKeepAliveTimer = this._register(new IntervalTimer()); + private _relayKeepAlivePending: Promise | undefined; private readonly _closeTimer = this._register(new TimeoutTimer()); /** @@ -494,7 +501,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect })); this._register(this._configurationService.onDidChangeConfiguration(e => { - if (this._state.kind !== AgentHostClientState.Connected) { + if (this._state.kind !== AgentHostClientState.Connected || this._transport.clientConnectionKind === AgentHostClientConnectionKind.WebPubSub) { return; } const patch: Record = {}; @@ -567,6 +574,10 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect this._diagnostic('transport.closeDetails', `code=${close.code ?? 'unavailable'}; wasClean=${close.wasClean ?? 'unavailable'}; reason=${sanitizeConnectionDiagnosticText(close.reason ?? '(unavailable)')}`); })); } + if (this._relayRootRefresh && this._relayRootRefresh.transport !== transport) { + this._subscriptionManager.cancelSnapshotRefresh(URI.parse(ROOT_STATE_URI)); + this._relayRootRefresh = undefined; + } this._transport = transport; this._transportListeners.value = listeners; } @@ -685,11 +696,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect this._logService.info(`[RemoteAgentHostProtocol] Initialized: address=${this._address} clientId=${this._clientId} durationMs=${watch.elapsed()}`); this._applyInitializeResult(result); // Keep the snapshot even if authentication must finish on a later replay reconnect. - for (const snapshot of result.snapshots ?? []) { - if (isAhpRootChannel(snapshot.resource)) { - this._subscriptionManager.handleRootSnapshot(snapshot.state as RootState, snapshot.fromSeq); - } - } + this._hydrateInitializedRootState(result); if (this._resolveInitialAuthentication || this._authentication.size > 0) { stage = 'authentication'; await this._traceConnection('protocol.authentication', () => this._restoreAuthentication(AgentHostClientState.Connecting)); @@ -728,7 +735,8 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect if (this._state.kind === AgentHostClientState.Reconnecting) { throw error; } - if (protocolError.code === AHP_CLIENT_CONNECTION_CLOSED && this._beginReconnectFromConnecting(protocolError)) { + if ((protocolError.code === AHP_CLIENT_CONNECTION_CLOSED || (stage === 'authentication' && protocolError.code === JSON_RPC_INTERNAL_ERROR)) + && this._beginReconnectFromConnecting(protocolError)) { throw error; } this._handleClose(protocolError); @@ -761,6 +769,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect * and let the service decide whether to spin up a fresh client. */ private _handleTransportClose(): void { + this._relayKeepAliveTimer.cancel(); if (this._state.kind !== AgentHostClientState.Closed) { this._diagnostic('transport.closed', `state=${this._state.kind}; sinceLastDataMs=${Date.now() - this._lastReadTime}`); } @@ -819,11 +828,11 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect return false; } if (!this._reconnectPolicy.autoRestore) { - this._logService.info(`[RemoteAgentHostProtocol] Transport lost while connecting to ${this._address}; automatic reconnect is disabled.`); + this._logService.info(`[RemoteAgentHostProtocol] Connection setup failed for ${this._address}; automatic reconnect is disabled.`); this._handleFatalClose(error); return true; } - this._logService.info(`[RemoteAgentHostProtocol] Transport lost while connecting to ${this._address}; scheduling a fresh initialize.`); + this._logService.info(`[RemoteAgentHostProtocol] Connection setup failed for ${this._address}; scheduling a fresh initialize.`); // Carry the pre-handshake outbox into the reconnect state so queued // messages are replayed once the fresh initialize succeeds. const outbox = this._state.outbox; @@ -914,13 +923,14 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect return; } reconnect.attempt++; + const attempt = reconnect.attempt; this._diagnosticAttemptId = generateUuid(); this._diagnostic('reconnect.started', `attempt=${reconnect.attempt}; clientId=${this._clientId}`); let transport: IProtocolTransport | undefined; try { if (this._prepareReconnect) { await this._raceClose(this._prepareReconnect()); - if (!this._checkReconnectState(reconnect)) { + if (!this._checkReconnectState(reconnect) || reconnect.attempt !== attempt) { return; } } @@ -990,12 +1000,13 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect this._lastReadTime = Date.now(); this._resetLivenessTimers(); this._transitionTo({ kind: AgentHostClientState.Connected }); + this._startRelayKeepAlive(result._meta ?? this._initializeResult.get()?._meta); gate.complete(); this._onDidReconnect.fire(); this._logService.info(`[RemoteAgentHostProtocol] Reconnected to ${this._address}.`); this._diagnostic('reconnect.succeeded', `attempt=${reconnect.attempt}`); } catch (err) { - if (!this._checkReconnectState(reconnect)) { + if (!this._checkReconnectState(reconnect) || reconnect.attempt !== attempt) { transport?.dispose(); return; } @@ -1030,9 +1041,9 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect } } - private async _reconnectOrInitialize(lastSeenServerSeq: number, subscriptions: string[]): Promise<{ result: CommandMap['reconnect']['result']; freshInitialize: boolean }> { + private async _reconnectOrInitialize(lastSeenServerSeq: number, subscriptions: string[]): Promise<{ result: CommandMap['reconnect']['result'] & { readonly _meta?: Record }; freshInitialize: boolean }> { try { - const result = await this._dispatchRequest('reconnect', { + const result = await this._dispatchRequest }>('reconnect', { channel: ROOT_STATE_URI, clientId: this._clientId, lastSeenServerSeq, @@ -1189,11 +1200,8 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect ? { bypassInitializeQueue: true, bypassReconnectGate: true } : { bypassReconnectGate: true }); } catch (error) { - // A dropped transport is not an authentication failure. Wrapping it - // would classify a momentary blip as terminally incompatible and - // permanently stop recovery, so let it stay a reconnectable error. - // The pending flag survives so the next attempt redelivers this. - if (isConnectionClosedError(error)) { + // Transport loss and host faults do not prove that the credential was rejected. + if (isConnectionClosedError(error) || (key === initialAuthenticationKey && error instanceof ProtocolError && error.code === JSON_RPC_INTERNAL_ERROR)) { throw error; } // Only the freshly resolved initial authentication is essential: @@ -1209,22 +1217,26 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect this._logService.warn(`[AgentHostProtocolClient] Failed to restore authentication for ${params.resource}: ${error instanceof Error ? error.message : String(error)}`); } })); + await this._refreshRelayRootSnapshot(); this._authenticationRestorePending = false; } private _clientMeta(): Record { const telemetryLevel = this._effectiveTelemetryLevel(); const sendIdentity = telemetryLevel >= TelemetryLevel.USAGE; - return toAgentHostClientMeta( - this._transport.clientConnectionKind, - telemetryLevel, - sendIdentity ? this._telemetryService.machineId : undefined, - sendIdentity ? this._telemetryService.devDeviceId : undefined, - ); + return { + ...toAgentHostClientMeta( + this._transport.clientConnectionKind, + telemetryLevel, + sendIdentity ? this._telemetryService.machineId : undefined, + sendIdentity ? this._telemetryService.devDeviceId : undefined, + ), [AgentHostSessionUrisCapabilityMetaKey]: true + }; } private _applyInitializeResult(result: IAgentHostExtensionInitializeResult, forwardClientConfig = true): void { this._initializeResult.set(result, undefined); + this._startRelayKeepAlive(result._meta); this._serverSeq = result.serverSeq; if (result.defaultDirectory) { const directory = result.defaultDirectory; @@ -1235,6 +1247,14 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect } } + private _hydrateInitializedRootState(result: IAgentHostExtensionInitializeResult): void { + for (const snapshot of result.snapshots ?? []) { + if (isAhpRootChannel(snapshot.resource)) { + this._subscriptionManager.handleRootSnapshot(snapshot.state as RootState, snapshot.fromSeq); + } + } + } + /** * Push the renderer-owned config values the host mirrors (telemetry level, * proxy discovery, migrate flag, …) as `RootConfigChanged` actions. Called on @@ -1249,6 +1269,9 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect * settings contributed by an extension rather than by core. */ private _forwardClientConfig(includeManagedSettings = true): void { + if (this._transport.clientConnectionKind === AgentHostClientConnectionKind.WebPubSub) { + return; + } this._dispatchRootConfig(resolveAgentHostConfigurationSyncPatch(this._configurationService, getAgentHostConfigurationSyncTarget(this._resourceIdentity))); this._updateTelemetryLevel(); this._updateTerminalAutoApproveEnabled(); @@ -1478,7 +1501,8 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect if (!provider) { throw new Error('Cannot create remote agent host session without a provider.'); } - const session = config?.session ?? AgentSession.uri(provider, generateUuid()); + const root = this.rootState.value; + const session = config?.session ?? newAgentHostSessionUri(provider, generateUuid(), this.initializeResult.get(), root instanceof Error ? undefined : root); if (config?.activeClient?.customizations) { this._grantImplicitReadsForCustomizations(config.activeClient.customizations); } @@ -1645,9 +1669,50 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect } else { this._authentication.delete(key); } + await this._refreshRelayRootSnapshot(); return { authenticated: true }; } + private _refreshRelayRootSnapshot(): Promise { + if (this._transport.clientConnectionKind !== AgentHostClientConnectionKind.WebPubSub) { + return Promise.resolve(); + } + if (this._relayRootRefresh?.transport === this._transport) { + return this._relayRootRefresh.promise; + } + const transport = this._transport; + const refresh = { transport, promise: this._readRelayRootSnapshot(transport) }; + this._relayRootRefresh = refresh; + const release = () => { + if (this._relayRootRefresh === refresh) { + this._relayRootRefresh = undefined; + } + }; + void refresh.promise.then(release, release); + return refresh.promise; + } + + private async _readRelayRootSnapshot(transport: IProtocolTransport): Promise { + const root = URI.parse(ROOT_STATE_URI); + this._subscriptionManager.beginSnapshotRefresh(root); + try { + const result = await this._dispatchRequest('subscribe', { channel: ROOT_STATE_URI }, { bypassInitializeQueue: true, bypassReconnectGate: true, timeoutMs: 30_000 }); + if (transport !== this._transport) { + throw transportLostError(this._address); + } + const snapshot = result.snapshot; + if (!snapshot || !isAhpRootChannel(snapshot.resource) || !hasKey(snapshot.state, { agents: true })) { + throw new ProtocolError(JSON_RPC_INTERNAL_ERROR, 'Relay root subscription returned no root snapshot'); + } + this._subscriptionManager.handleRootSnapshot(snapshot.state, snapshot.fromSeq); + this._serverSeq = Math.max(this._serverSeq, snapshot.fromSeq); + } finally { + if (transport === this._transport) { + this._subscriptionManager.cancelSnapshotRefresh(root); + } + } + } + private _normalizeAuthenticationParams(params: AuthenticateParams): AuthenticateParams { return { ...params, @@ -1828,36 +1893,40 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect */ async listSessions(): Promise<(IAgentSessionMetadata & { readonly workingDirectory?: URI })[]> { const result = await this._sendRequest('listSessions', { channel: ROOT_STATE_URI }); - return result.items.map((s: SessionSummary) => ({ - session: URI.parse(s.resource), - provider: s.provider, - startTime: Date.parse(s.createdAt), - modifiedTime: Date.parse(s.modifiedAt), - ...(s.project ? { - project: { - uri: this._toClientUri(URI.parse(s.project.uri)), - displayName: s.project.displayName, - } - } : {}), - summary: s.title, - status: s.status, - activity: s.activity, - workingDirectory: typeof s.workingDirectories?.[0] === 'string' ? this._toClientUri(URI.parse(s.workingDirectories[0])) : undefined, - workingDirectories: s.workingDirectories?.map(d => this._toClientUri(URI.parse(d))), - changes: s.changes, - chats: s.chats?.map(chat => ({ - chat: URI.parse(chat.resource), - summary: chat.title, - kind: s.defaultChat === chat.resource || isDefaultChatUri(chat.resource) ? 'default' : 'peer', - origin: chat.origin, - ...(chat.interactivity !== undefined ? { interactivity: chat.interactivity } : {}), - ...(isSessionStatusArchived(chat.status) || chat.archived === true ? { archived: true } : {}), - ...(chat.status !== undefined ? { isRead: isSessionStatusRead(chat.status) } : {}), - ...(chat.changes !== undefined ? { changes: chat.changes } : {}), - })), - // Carry durable host provenance for sessions first materialized from a listing. - ...(s._meta !== undefined ? { _meta: s._meta } : {}), - })); + return result.items.map((s: SessionSummary) => { + const model = readCodexSessionModel(s); + return { + session: URI.parse(s.resource), + provider: s.provider, + startTime: Date.parse(s.createdAt), + modifiedTime: Date.parse(s.modifiedAt), + ...(s.project ? { + project: { + uri: this._toClientUri(URI.parse(s.project.uri)), + displayName: s.project.displayName, + } + } : {}), + summary: s.title, + status: s.status, + activity: s.activity, + workingDirectory: typeof s.workingDirectories?.[0] === 'string' ? this._toClientUri(URI.parse(s.workingDirectories[0])) : undefined, + workingDirectories: s.workingDirectories?.map(d => this._toClientUri(URI.parse(d))), + changes: s.changes, + ...(model ? { model } : {}), + chats: s.chats?.map(chat => ({ + chat: URI.parse(chat.resource), + summary: chat.title, + kind: s.defaultChat === chat.resource || isDefaultChatUri(chat.resource) ? 'default' : 'peer', + origin: chat.origin, + ...(chat.interactivity !== undefined ? { interactivity: chat.interactivity } : {}), + ...(isSessionStatusArchived(chat.status) || chat.archived === true ? { archived: true } : {}), + ...(chat.status !== undefined ? { isRead: isSessionStatusRead(chat.status) } : {}), + ...(chat.changes !== undefined ? { changes: chat.changes } : {}), + })) ?? (s.defaultChat ? [{ chat: URI.parse(s.defaultChat), kind: 'default' as const }] : undefined), + // Carry durable host provenance for sessions first materialized from a listing. + ...(s._meta !== undefined ? { _meta: s._meta } : {}), + }; + }); } private _toClientUri(uri: URI): URI { @@ -2078,6 +2147,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect } private _handleFatalClose(error: ProtocolError, reason?: AgentHostTransportFailureReason): void { + this._relayKeepAliveTimer.cancel(); this._onDidFatalClose.fire(error); this._handleClose(error, reason); } @@ -2370,6 +2440,9 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect /** Merge a patch into the agent host's root configuration. */ private _dispatchRootConfig(config: Record): void { + if (this._transport.clientConnectionKind === AgentHostClientConnectionKind.WebPubSub) { + return; + } this.dispatchAction(ROOT_STATE_URI, { type: ActionType.RootConfigChanged, config, @@ -2396,6 +2469,9 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect } private _updateManagedSettingsPermissions(sendDuringReconnect = false): void { + if (this._transport.clientConnectionKind === AgentHostClientConnectionKind.WebPubSub) { + return; + } const permissions = this._resourceIdentity === LOCAL_AGENT_HOST_RESOURCE_IDENTITY ? resolveManagedSettingsPermissions(this._configurationService) : {}; @@ -2415,7 +2491,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect private async _dispatchRequest( method: string, params: unknown, - options: { readonly bypassInitializeQueue?: boolean; readonly allowIncompatibleUpgrade?: boolean; readonly bypassReconnectGate?: boolean } = {}, + options: { readonly bypassInitializeQueue?: boolean; readonly allowIncompatibleUpgrade?: boolean; readonly bypassReconnectGate?: boolean; readonly timeoutMs?: number } = {}, ): Promise { if (this._state.kind === AgentHostClientState.Closed) { throw this._state.error; @@ -2424,12 +2500,12 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect if (!options.allowIncompatibleUpgrade) { throw this._state.error; } - const { request, result } = this._createRequest(method, params); + const { request, result } = this._createRequest(method, params, options.timeoutMs); this._transport.send(request); return result; } if (!options.bypassInitializeQueue && isClientTransport(this._transport) && this._state.kind === AgentHostClientState.Connecting) { - const { request, result } = this._createRequest(method, params); + const { request, result } = this._createRequest(method, params, options.timeoutMs); this._state.outbox.push(request as ProtocolMessage); return result; } @@ -2460,7 +2536,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect throw current.error; } - const { request, result } = this._createRequest(method, params); + const { request, result } = this._createRequest(method, params, options.timeoutMs); if (method === 'initialize') { this._logService.info(`[RemoteAgentHostProtocol] Sending initialize: address=${this._address} clientId=${this._clientId} requestId=${request.id}`); } @@ -2475,10 +2551,19 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect return result; } - private _createRequest(method: string, params: unknown): { request: JsonRpcRequest; result: Promise } { + private _createRequest(method: string, params: unknown, timeoutMs?: number): { request: JsonRpcRequest; result: Promise } { const id = this._nextRequestId++; const deferred = new DeferredPromise(); this._pendingRequests.set(id, { deferred, suppressNotFoundWarning: isFileResourceRead(method, params), sentAt: Date.now() }); + if (timeoutMs !== undefined) { + const deadline = disposableTimeout(() => { + if (this._pendingRequests.get(id)?.deferred === deferred) { + this._pendingRequests.delete(id); + deferred.error(new ProtocolError(JSON_RPC_INTERNAL_ERROR, `${method} response timed out`)); + } + }, timeoutMs, this._store); + void deferred.p.then(() => deadline.dispose(), () => deadline.dispose()); + } return { request: { jsonrpc: '2.0', id, method, params }, result: deferred.p as Promise, @@ -2539,6 +2624,29 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect * silent handshake cannot leave subscription requests gated indefinitely. * An inbound message also clears any deferred liveness state. */ + private _startRelayKeepAlive(meta: Record | undefined): void { + this._relayKeepAliveTimer.cancel(); + const window = readRelayKeepAliveTimeout({ _meta: meta }); + if (this._transport.clientConnectionKind !== AgentHostClientConnectionKind.WebPubSub + || window === undefined) { + return; + } + this._relayKeepAliveTimer.cancelAndSet(() => { + if (this._state.kind !== AgentHostClientState.Connected || this._relayKeepAlivePending) { + return; + } + const operation = this._dispatchRequest('ping', { channel: ROOT_STATE_URI }, { timeoutMs: 10_000 }); + this._relayKeepAlivePending = operation; + void operation.catch(error => { + this._logService.warn('[AgentHostProtocolClient] Relay keep-alive failed', error); + }).finally(() => { + if (this._relayKeepAlivePending === operation) { + this._relayKeepAlivePending = undefined; + } + }); + }, Math.max(1, Math.min(60_000, Math.floor(window / 3)))); + } + private _resetLivenessTimers(): void { this._cancelLivenessTimers(); if (!this._canCheckLiveness()) { diff --git a/src/vs/platform/agentHost/browser/webPubSubRelayTransport.ts b/src/vs/platform/agentHost/browser/webPubSubRelayTransport.ts index f92746c42f6e65..a97cbdf4282895 100644 --- a/src/vs/platform/agentHost/browser/webPubSubRelayTransport.ts +++ b/src/vs/platform/agentHost/browser/webPubSubRelayTransport.ts @@ -15,12 +15,12 @@ import { Emitter } from '../../../base/common/event.js'; import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; import { IntervalTimer, RunOnceScheduler, disposableTimeout } from '../../../base/common/async.js'; -import { isObject } from '../../../base/common/types.js'; +import { hasKey, isObject } from '../../../base/common/types.js'; import { StopWatch } from '../../../base/common/stopwatch.js'; import { ILogService } from '../../log/common/log.js'; import { AgentHostClientConnectionKind } from '../common/agentHostTelemetry.js'; import { AhpJsonlLogger, getAhpLogByteLength } from '../common/ahpJsonlLogger.js'; -import type { AhpServerNotification, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, ProtocolMessage } from '../common/state/sessionProtocol.js'; +import { isJsonRpcResponse, type AhpServerNotification, type JsonRpcNotification, type JsonRpcRequest, type JsonRpcResponse, type ProtocolMessage } from '../common/state/sessionProtocol.js'; import type { IClientTransport } from '../common/state/sessionTransport.js'; import { Reassembler } from '../common/webPubSub/chunking.js'; import { type InboundResult, RELIABLE_JSON_SUBPROTOCOL, buildPublish, parseInbound } from '../common/webPubSub/framing.js'; @@ -29,7 +29,7 @@ import type { ParseGroupNameOptions } from '../common/webPubSub/groups.js'; /** How often to sweep the reassembler for abandoned partial-chunk buffers. */ const REASSEMBLY_SWEEP_INTERVAL_MS = 15_000; -/** Upper bound on the WPS handshake and publish acknowledgement waits, not host execution. */ +/** Upper bound on relay joins, AHP handshakes and publish acknowledgement waits, not session execution. */ const WPS_TIMEOUT_MS = 30_000; /** @@ -126,6 +126,10 @@ export class WebPubSubRelayTransport extends Disposable implements IClientTransp private readonly _reassembler = new Reassembler(); private readonly _sweepTimer = this._register(new IntervalTimer()); private readonly _publishAckTimer = this._register(new RunOnceScheduler(() => this._checkPublishAckTimeout(), WPS_TIMEOUT_MS)); + private readonly _handshakeTimer = this._register(new RunOnceScheduler(() => { + this._reportProtocolError('AHP handshake timed out', new Error('WPS host handshake timed out'), true); + this._fireClose(); + }, WPS_TIMEOUT_MS)); private _ws: IWebSocketLike | undefined; private _ackId = 0; @@ -138,6 +142,10 @@ export class WebPubSubRelayTransport extends Disposable implements IClientTransp /** Guards against firing onClose / resolving connect more than once. */ private _closed = false; private _connectResolved = false; + private _generation: number | undefined; + private _handshakeId: JsonRpcRequest['id'] | undefined; + private readonly _requests = new Set(); + private readonly _closedBeforeHandshake = new Set(); private readonly _watch = StopWatch.create(false); private _publishAcknowledged = false; private _hostFrames = 0; @@ -364,31 +372,99 @@ export class WebPubSubRelayTransport extends Disposable implements IClientTransp this._reportProtocolError('invalid group message', err); return; } - if (result.kind === 'payload' || result.kind === 'pending') { + if (result.kind === 'payload' || result.kind === 'batch' || result.kind === 'pending') { this._lastHostFrameMs = this._watch.elapsed(); if (++this._hostFrames === 1) { this._logService.info(`${this._logContext()} first host frame received`); } } - if (result.kind === 'payload') { - this._hostMessages++; - const payload = result.payload as ProtocolMessage; - this._options.ahpLogger?.log(payload, 's2c', getAhpLogByteLength(JSON.stringify(payload))); - this._onMessage.fire(payload); + if (result.kind === 'closed') { + if (result.group.scope === 'client' && result.group.lane === 'to-client') { + if (this._handshakeId !== undefined) { + if (result.generation !== undefined) { + this._closedBeforeHandshake.add(result.generation); + if (this._closedBeforeHandshake.size > 128) { + this._options.onProtocolError?.(new Error('Relay handshake closure window exceeded')); + this._fireClose(); + } + } + } else if (result.generation === undefined || result.generation === this._generation) { + this._fireClose(); + } + } + } else if (result.kind === 'payload' || result.kind === 'batch') { + for (const value of result.kind === 'batch' ? result.payloads : [result.payload]) { + if (this._closed) { + break; + } + this._deliver(value as ProtocolMessage, result.generation); + } } else if (result.kind === 'pending') { + if (this._handshakeId !== undefined && result.group.scope === 'client' && result.group.lane === 'to-client') { + this._handshakeTimer.schedule(); + } // Only host chunks count: relay acks and system frames arrive even when the host is dead. this._onDidReceiveData.fire(); } } + private _deliver(payload: ProtocolMessage, generation: number | undefined): void { + if (!isObject(payload) || payload.jsonrpc !== '2.0') { + this._options.onProtocolError?.(new Error('Invalid AHP relay message')); + return; + } + const response = isJsonRpcResponse(payload); + if (this._handshakeId !== undefined && !(response && payload.id === this._handshakeId)) { + return; + } + if (response && payload.id !== null && payload.id === this._handshakeId) { + if (generation !== undefined && this._closedBeforeHandshake.has(generation)) { + this._fireClose(); + return; + } + this._generation = generation; + this._handshakeId = undefined; + this._handshakeTimer.cancel(); + this._closedBeforeHandshake.clear(); + this._requests.clear(); + } else if (generation !== undefined && this._generation !== undefined && generation !== this._generation) { + if (response && payload.id !== null && this._requests.has(payload.id)) { + this._fireClose(); + } + return; + } + if (response && payload.id !== null) { + this._requests.delete(payload.id); + } + this._hostMessages++; + this._logProtocolMessage(payload, 's2c'); + this._onMessage.fire(payload); + } + /** Publish each chunk once; rejection or a missing acknowledgement after 30 seconds fails the transport. */ send(message: ProtocolMessage | AhpServerNotification | JsonRpcNotification | JsonRpcResponse | JsonRpcRequest): void { if (this._closed || !this._ws) { throw new Error('WebPubSubRelayTransport is closed'); } + if (hasKey(message, { method: true, params: true }) && message.method === 'authenticate' + && (!isObject(message.params) || typeof message.params['token'] !== 'string' || !message.params['token'].startsWith('copilot-sealed.v1.'))) { + throw new Error('Refusing to send plaintext authentication over Web PubSub'); + } + if (hasKey(message, { id: true, method: true }) && (typeof message.id === 'number' || typeof message.id === 'string')) { + if (this._requests.size >= 1024) { + this._fireClose(); + throw new Error('Web PubSub outstanding request limit exceeded'); + } + this._requests.add(message.id); + if (message.method === 'initialize' || message.method === 'reconnect') { + this._handshakeId = message.id; + this._closedBeforeHandshake.clear(); + this._handshakeTimer.schedule(); + } + } // Logged before chunking, so the transcript carries whole AHP messages rather than the // relay frames they were split into. - this._options.ahpLogger?.log(message, 'c2s', getAhpLogByteLength(JSON.stringify(message))); + this._logProtocolMessage(message, 'c2s'); const frames = buildPublish({ group: this._options.toHostGroup, nextAckId: () => ++this._ackId, @@ -407,6 +483,16 @@ export class WebPubSubRelayTransport extends Disposable implements IClientTransp } } + private _logProtocolMessage(message: ProtocolMessage | AhpServerNotification | JsonRpcNotification | JsonRpcResponse | JsonRpcRequest, direction: 'c2s' | 's2c'): void { + if (!this._options.ahpLogger) { + return; + } + const logged = hasKey(message, { method: true, params: true }) && message.method === 'authenticate' && isObject(message.params) + ? { ...message, params: { ...message.params, token: '[REDACTED]' } } + : message; + this._options.ahpLogger.log(logged, direction, getAhpLogByteLength(JSON.stringify(message))); + } + private _schedulePublishAckTimeout(): void { const deadline = this._pendingPublishAcks.values().next().value; if (deadline === undefined) { @@ -466,8 +552,11 @@ export class WebPubSubRelayTransport extends Disposable implements IClientTransp this._closed = true; this._sweepTimer.cancel(); this._publishAckTimer.cancel(); + this._handshakeTimer.cancel(); this._pendingJoinAcks.clear(); this._pendingPublishAcks.clear(); + this._requests.clear(); + this._closedBeforeHandshake.clear(); this._rejectConnect = undefined; try { this._ws?.close(); diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts index 8eda7dede66d62..9b4882b5ce1c6e 100644 --- a/src/vs/platform/agentHost/common/agent.ts +++ b/src/vs/platform/agentHost/common/agent.ts @@ -14,7 +14,7 @@ import { isEqual } from '../../../base/common/resources.js'; import { URI } from '../../../base/common/uri.js'; import type { IAgentServerToolHost } from './agentServerTools.js'; import type { AgentHostClientType } from './agentHostClientInfo.js'; -import type { IAgentHostClientTelemetryContext, IAgentProviderSendStageRecorder, IAgentProviderTurnTelemetryContext } from './agentHostTelemetry.js'; +import type { CodexModelProvider, IAgentTurnTelemetryCorrelation, IAgentHostClientTelemetryContext, IAgentProviderSendStageRecorder, IAgentProviderTurnTelemetryContext } from './agentHostTelemetry.js'; import type { AgentPermissionDecisionSource } from './meta/agentPermissionResponseMeta.js'; import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from './state/protocol/commands.js'; import type { CanvasState } from './state/protocol/channels-canvas/state.js'; @@ -507,6 +507,9 @@ export interface IAgentChatContext { readonly clientTelemetryContext?: IAgentHostClientTelemetryContext; /** The owning turn's immutable admission snapshot, supplied only for its send. */ readonly turnTelemetryContext?: IAgentProviderTurnTelemetryContext; + readonly turnTelemetryCorrelation?: IAgentTurnTelemetryCorrelation; + /** Records provider-owned dispatch facts on the addressed turn, before progress can complete it. */ + readonly reportCodexModelProvider?: (provider: CodexModelProvider) => void; /** * The addressed chat's origin, taken verbatim from the host-owned chat * catalog, and exhaustive across every way a chat comes into existence: @@ -878,6 +881,16 @@ export interface IAgentChats { } +/** Provider-native metadata events, before protocol translation. */ +export interface IAgentChatSessionEvent { + readonly chat: URI; + readonly id: string; + readonly timestamp: string; + readonly persisted: boolean; + readonly type: string; + readonly data: unknown; +} + export interface IAgentResolveChatConfigParams { readonly provider?: AgentProvider; readonly workingDirectory?: URI; @@ -1154,12 +1167,11 @@ export namespace AgentSession { } /** - * Extracts the provider name from a session URI scheme. - * Accepts both a URI object and a URI string. + * Legacy provider-scheme fallback; standard sessions require explicit provider metadata. */ export function provider(session: URI | string): AgentProvider | undefined { const parsed = typeof session === 'string' ? URI.parse(session) : session; - return parsed.scheme || undefined; + return parsed.scheme === 'ahp-session' ? undefined : parsed.scheme || undefined; } } @@ -1300,6 +1312,15 @@ export interface IAgent { /** Streamed progress for an exact chat. */ readonly onDidChatProgress: Event; + /** Optional admitted native metadata stream for environment-owned session mirroring. */ + readonly onDidChatSessionEvent?: Event; + + /** Read genuine persisted metadata without creating or resuming a provider session. */ + readChatSessionEvents?(chat: URI, context: IAgentChatContext, token: CancellationToken, afterEventId?: string, onDidReadEventId?: (id: string) => void): AsyncIterable; + + /** Synchronize a host-owned title into an already-live provider session. */ + synchronizeChatSessionTitle?(chat: URI, title: string): Promise; + /** Fires when a provisional chat acquires its SDK backing and durable metadata. */ readonly onDidMaterializeChat: Event; @@ -1329,6 +1350,9 @@ export interface IAgent { */ setWorkingDirectory(chat: URI, context: URI | IAgentChatContext, workingDirectory: URI): Promise; + /** Changes the addressed chat's backing. Shared roots may change only under the host's single-chat catalog lock. */ + setChatWorkingDirectory?(chat: URI, context: IAgentChatContext, workingDirectory: URI, options?: { readonly replaceSessionWorkspace: boolean }): Promise; + /** Return bounded diagnostics for an in-flight turn when supported. */ getTurnDiagnosticSnapshot?(chat: URI, turnId: string): IAgentTurnDiagnosticSnapshot | undefined; @@ -1466,6 +1490,8 @@ export interface IAgent { /** Optional managed-settings snapshot for providers with an enterprise policy surface. */ getManagedSettingsDiagnostics?(): Promise; + /** Canonical device remote-control policy; absence is distinct from a failed read. */ + getRemoteControlManagedSettings?(): Promise | undefined>; /** Return the provider-owned state file for a session, when one exists. */ getSessionStateFile?(session: URI, chat?: URI): Promise; diff --git a/src/vs/platform/agentHost/common/agentHostByokLm.ts b/src/vs/platform/agentHost/common/agentHostByokLm.ts index fef22bec3b8bcc..a9e0ee398cf272 100644 --- a/src/vs/platform/agentHost/common/agentHostByokLm.ts +++ b/src/vs/platform/agentHost/common/agentHostByokLm.ts @@ -59,7 +59,15 @@ export interface IByokLmFunctionCallItem { export interface IByokLmFunctionCallOutputItem { readonly type: 'function_call_output'; readonly callId: string; + /** Text of the tool result. */ readonly output: string; + /** + * The tool result's ordered text and image parts, set only when it carries + * images (e.g. the `view` tool reading an image file). Supersedes + * {@link output}; `output` stays populated so a renderer that predates this + * field still receives the text result. + */ + readonly content?: IByokLmContentPart[]; } export interface IByokLmCustomToolCallItem { @@ -72,7 +80,10 @@ export interface IByokLmCustomToolCallItem { export interface IByokLmCustomToolCallOutputItem { readonly type: 'custom_tool_call_output'; readonly callId: string; + /** Text of the tool result. */ readonly output: string; + /** Ordered text and image parts; see {@link IByokLmFunctionCallOutputItem.content}. */ + readonly content?: IByokLmContentPart[]; } export type IByokLmInputItem = @@ -182,7 +193,7 @@ export function getByokLmAgentModelId(model: IByokLmModelInfo): string { /** Resolves BYOK enablement and trace context from synchronized root configuration. */ export function resolveByokLmEnablement(rootConfigValue: boolean | undefined): { readonly enabled: boolean; readonly trace: string } { - const enabled = rootConfigValue === true; + const enabled = rootConfigValue !== false; return { enabled, trace: `enabled: ${enabled} (root config: ${rootConfigValue ?? 'unset'})`, diff --git a/src/vs/platform/agentHost/common/agentHostChatIdentity.ts b/src/vs/platform/agentHost/common/agentHostChatIdentity.ts new file mode 100644 index 00000000000000..32008d1d95506e --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostChatIdentity.ts @@ -0,0 +1,12 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { parseChatUri } from './state/sessionState.js'; + +/** Preserves existing native chat fragments and uses the complete resource for opaque host chats. */ +export function getAgentHostChatId(resource: URI | string): string { + return parseChatUri(resource)?.chatId ?? resource.toString(); +} diff --git a/src/vs/platform/agentHost/common/agentHostConnectionsService.ts b/src/vs/platform/agentHost/common/agentHostConnectionsService.ts index 6fc30ae3858788..20bdba268beb42 100644 --- a/src/vs/platform/agentHost/common/agentHostConnectionsService.ts +++ b/src/vs/platform/agentHost/common/agentHostConnectionsService.ts @@ -67,6 +67,8 @@ export interface IAgentHostSessionIdentity { /** Provider-owned remote address, retained even when the live connection has been removed. */ readonly connectionAddress?: string; readonly backendSession: URI; + /** The backend resource was advertised, not reconstructed by the bounded legacy fallback. */ + readonly backendSessionIsAdvertised?: true; readonly defaultChangesetKind?: DefaultChangesetKind; } @@ -143,6 +145,18 @@ export interface IAgentHostConnectionsService { */ registerSessionResolutionPolicy(authority: string, policy: IAgentHostSessionResolutionPolicy): IDisposable; + /** Records an exact backend identity and adapts only its frontend provider scheme. */ + registerSessionResource(backendSession: URI, authority?: string, provider?: string): URI; + + /** Looks up a frontend resource using its advertised backend identity on one connection. */ + findSessionResource(backendSession: URI, authority?: string): URI | undefined; + + /** + * Maps a backend session through provider policy only when the resource + * resolves back to that exact session. Unsupported identities return undefined. + */ + getSessionResource(backendSession: URI, authority?: string): URI | undefined; + /** * Resolves an agent-host chat-session resource to its connection authority * and backend session URI without requiring the host to be connected. diff --git a/src/vs/platform/agentHost/common/agentHostExtensionProtocol.ts b/src/vs/platform/agentHost/common/agentHostExtensionProtocol.ts index 33efc6fa0b7393..19d50f6a2a9d87 100644 --- a/src/vs/platform/agentHost/common/agentHostExtensionProtocol.ts +++ b/src/vs/platform/agentHost/common/agentHostExtensionProtocol.ts @@ -15,6 +15,7 @@ import { AgentHostTimingCapabilityMetaKey, ChatUserInteractionCapability } from import type { IAgentHostFirstResponseDiagnostic } from './otel/agentHostTiming.js'; import type { IChatUserInteractionTiming } from '../../otel/common/chatUserInteraction.js'; import { AgentHostAutonomousAutomationsCapabilityMetaKey } from './meta/agentHostAutomationsMeta.js'; +import { AgentHostNativeImplementationMetaKey, AgentHostSessionUrisCapabilityMetaKey } from './meta/agentHostSessionUrisMeta.js'; export { supportsAgentHostArtifactRemoval } from './meta/agentHostArtifactRemovalMeta.js'; export { supportsAgentHostDevContainers } from './meta/agentHostDevContainersMeta.js'; @@ -61,6 +62,8 @@ const AgentHostDetachedWorktreeCapabilityMetaKey = 'vscode.detachedWorktrees'; /** Namespaced VS Code implementation capabilities carried alongside standardized AHP initialize capabilities. */ export interface IAgentHostExtensionInitializeResultMeta extends Record { + readonly [AgentHostSessionUrisCapabilityMetaKey]?: true; + readonly [AgentHostNativeImplementationMetaKey]?: true; readonly [AgentHostChatStateFileCapabilityMetaKey]?: true; readonly [AgentHostDetachedWorktreeCapabilityMetaKey]?: true; readonly [AgentHostArtifactRemovalCapabilityMetaKey]?: true; @@ -79,6 +82,8 @@ export interface IAgentHostExtensionInitializeResult extends InitializeResult { export function getAgentHostExtensionInitializeResultMeta(artifactRemoval = true, devContainers = false, timing = false, sessionImport = false): IAgentHostExtensionInitializeResultMeta { return { + [AgentHostSessionUrisCapabilityMetaKey]: true, + [AgentHostNativeImplementationMetaKey]: true, [AgentHostChatStateFileCapabilityMetaKey]: true, [AgentHostDetachedWorktreeCapabilityMetaKey]: true, [AgentHostAutonomousAutomationsCapabilityMetaKey]: true, diff --git a/src/vs/platform/agentHost/common/agentHostGitService.ts b/src/vs/platform/agentHost/common/agentHostGitService.ts index 58ccc376095414..a5876df05e297d 100644 --- a/src/vs/platform/agentHost/common/agentHostGitService.ts +++ b/src/vs/platform/agentHost/common/agentHostGitService.ts @@ -14,7 +14,7 @@ import { ISessionFileDiff, ISessionGitState } from './state/sessionState.js'; * Provider-agnostic session-database metadata key under which agents * persist the branch they want git-driven diffs anchored to. Read by * {@link IAgentHostChangesetService} when computing per-session file diffs; absent - * value means the diff falls back to anchoring at HEAD. + * or cleared value falls back to the detected base branch, then HEAD. */ export const META_DIFF_BASE_BRANCH = 'agentHost.diffBaseBranch'; @@ -28,7 +28,7 @@ export const META_DIFF_BASE_BRANCH = 'agentHost.diffBaseBranch'; * pick the same base branch. */ export function resolveDiffBaseBranchName(persistedBaseBranch: string | undefined, sessionGitStateBaseBranch: string | undefined): string | undefined { - const branchName = persistedBaseBranch ?? sessionGitStateBaseBranch; + const branchName = persistedBaseBranch || sessionGitStateBaseBranch; if (!branchName) { return undefined; } diff --git a/src/vs/platform/agentHost/common/agentHostGitStateService.ts b/src/vs/platform/agentHost/common/agentHostGitStateService.ts index c7a6da0218fbd8..9e7724862df9ab 100644 --- a/src/vs/platform/agentHost/common/agentHostGitStateService.ts +++ b/src/vs/platform/agentHost/common/agentHostGitStateService.ts @@ -64,6 +64,9 @@ export interface IAgentHostGitStateService { /** Merges the branch identity known when an isolated worktree materializes into session metadata. */ getMaterializedWorktreeMeta(sessionKey: string, branchName: string): SessionSummaryMeta | undefined; + /** Publishes and persists Git state for a chat's destination before its workspace changes. */ + setFolderGitState(sessionKey: string, workingDirectories: readonly string[], gitState: ISessionGitState | undefined): Promise; + /** Resolves the canonical base branch selected for a session. */ resolveSessionBaseBranchName(sessionKey: string): Promise; diff --git a/src/vs/platform/agentHost/common/agentHostOperatingSystem.ts b/src/vs/platform/agentHost/common/agentHostOperatingSystem.ts index 3a7f55c6eb47a5..f66e14b78ea40d 100644 --- a/src/vs/platform/agentHost/common/agentHostOperatingSystem.ts +++ b/src/vs/platform/agentHost/common/agentHostOperatingSystem.ts @@ -5,6 +5,7 @@ import { OperatingSystem } from '../../../base/common/platform.js'; import type { IAgentConnection } from './agentService.js'; +import { readAgentHostResources } from './meta/agentHostResources.js'; const operatingSystems = new WeakMap>(); @@ -24,6 +25,16 @@ export function getAgentHostOperatingSystem(connection: IAgentConnection): Promi } async function resolveOperatingSystem(connection: IAgentConnection): Promise { + const root = connection.rootState?.value; + const platform = readAgentHostResources(root instanceof Error ? undefined : root)?.platform; + switch (platform) { + case 'windows': + return OperatingSystem.Windows; + case 'macos': + return OperatingSystem.Macintosh; + case 'linux': + return OperatingSystem.Linux; + } const { os } = await connection.getNetworkDiagnosticsInfo(); switch (os) { case 'win32': diff --git a/src/vs/platform/agentHost/common/agentHostSchema.ts b/src/vs/platform/agentHost/common/agentHostSchema.ts index 5e9dc3a41fbc47..3e77553ade8dea 100644 --- a/src/vs/platform/agentHost/common/agentHostSchema.ts +++ b/src/vs/platform/agentHost/common/agentHostSchema.ts @@ -451,6 +451,8 @@ export const AgentHostSessionSyncEnabledConfigKey = 'sessionSyncEnabled'; /** Whether extension-provided BYOK models are enabled. */ export const AgentHostByokModelsEnabledConfigKey = 'byokModelsEnabled'; +/** Shared default for the root config and mirrored workbench setting. */ +export const AgentHostByokModelsEnabledDefault = true; /** * Root config key forwarded from the renderer carrying the experiment-aware @@ -554,6 +556,10 @@ const agentHostProxyConfigDefinition = { }; export const agentHostProxyConfigSchema = createSchema(agentHostProxyConfigDefinition); +/** Root config keys forwarded from the renderer for automatic title generation. */ +export const AgentHostActiveAgentTitleGenerationConfigKey = 'activeAgentTitleGeneration'; +export const AgentHostDeferredTitleGenerationConfigKey = 'deferredTitleGeneration'; + /** Root config key controlling rich-link guidance for Markdown plan documents. */ export const AgentHostMarkdownPlanRichLinksEnabledConfigKey = 'markdownPlanRichLinksEnabled'; @@ -835,7 +841,7 @@ export const platformRootSchema = createSchema({ type: 'boolean', title: localize('agentHost.config.byokModelsEnabled.title', "BYOK Models"), description: localize('agentHost.config.byokModelsEnabled.description', "Whether extension-provided BYOK models are enabled."), - default: false, + default: AgentHostByokModelsEnabledDefault, }), [AgentHostCodexEnabledConfigKey]: schemaProperty({ type: 'boolean', @@ -899,6 +905,18 @@ export const platformRootSchema = createSchema({ description: localize('agentHost.config.mcpToolRoutingEnabled.description', "Whether Copilot agent sessions use cached MCP tool metadata for routing."), default: false, }), + [AgentHostActiveAgentTitleGenerationConfigKey]: schemaProperty({ + type: 'boolean', + title: localize('agentHost.config.activeAgentTitleGeneration.title', "Active Agent Title Generation"), + description: localize('agentHost.config.activeAgentTitleGeneration.description', "Whether the active agent names sessions and chats with rename tools instead of immediate utility-model title generation. Deferred title generation takes precedence. Changes apply to new sessions."), + default: false, + }), + [AgentHostDeferredTitleGenerationConfigKey]: schemaProperty({ + type: 'boolean', + title: localize('agentHost.config.deferredTitleGeneration.title', "Deferred Title Generation"), + description: localize('agentHost.config.deferredTitleGeneration.description', "Seed titles immediately and refine them in the background if the first response turn completes successfully, without asking the active agent to name chats. Explicit rename tools remain available. Overrides active agent title generation for new sessions; existing sessions and their chats retain their strategy."), + default: false, + }), [AgentHostMcpConnectorsEnabledConfigKey]: schemaProperty({ type: 'boolean', title: localize('agentHost.config.mcpConnectorsEnabled.title', "Copilot Connectors"), diff --git a/src/vs/platform/agentHost/common/agentHostSessionIdentity.ts b/src/vs/platform/agentHost/common/agentHostSessionIdentity.ts new file mode 100644 index 00000000000000..bc23abf467312f --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostSessionIdentity.ts @@ -0,0 +1,16 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { AgentSession } from './agent.js'; +import { isNativeAgentHost, supportsAgentHostSessionUris } from './meta/agentHostSessionUrisMeta.js'; +import type { InitializeResult } from './state/protocol/common/commands.js'; +import type { RootState } from './state/sessionState.js'; + +/** Unmarked conforming hosts use AHP addressing; only a positively identified older native host needs legacy creation. */ +export function newAgentHostSessionUri(provider: string, id: string, host: InitializeResult | undefined, root?: RootState): URI { + const legacyNativeHost = !host || isNativeAgentHost(host, root) && !supportsAgentHostSessionUris(host); + return AgentSession.uri(legacyNativeHost ? provider : 'ahp-session', id); +} diff --git a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts index 059b74c9f9aede..4c3d41ecd76461 100644 --- a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts +++ b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts @@ -44,6 +44,7 @@ import { AgentHostAgentOrchestrationLimitsConfigKey, AgentHostAutoAttachPullRequestsConfigKey, AgentHostByokModelsEnabledConfigKey, + AgentHostByokModelsEnabledDefault, AgentHostCanvasesEnabledConfigKey, AgentHostClaudeMultiRootEnabledConfigKey, AgentHostCodexEnabledConfigKey, @@ -58,6 +59,7 @@ import { } from './agentHostSchema.js'; import { AgentMergeConfigKey, AgentMergeSettingId, AGENT_MERGE_SETTING_TAG } from './agentMerge.js'; import { artifactToolsConfigurationProperties } from './artifactToolsConfiguration.js'; +import { titleGenerationConfigurationProperties } from './titleGenerationConfiguration.js'; // Settings consumed by the agent host starter (`electronAgentHostStarter.ts` // and `nodeAgentHostStarter.ts`) to populate the spawned agent host process's @@ -191,6 +193,7 @@ configurationRegistry.registerConfiguration({ tags: ['experimental', AGENT_MERGE_SETTING_TAG], agentHost: { key: AgentMergeConfigKey.ReplyAttribution }, }, + ...titleGenerationConfigurationProperties, ...artifactToolsConfigurationProperties, [CanvasesEnabledSettingId]: { type: 'boolean', @@ -325,7 +328,7 @@ configurationRegistry.registerConfiguration({ [AgentHostByokModelsEnabledSettingId]: { type: 'boolean', description: nls.localize('chat.agentHost.byokModels.enabled', "When enabled, extension-provided BYOK ('bring your own key') models can run in agent-host sessions. Changes are synchronized to the running agent host and do not require a restart."), - default: false, + default: AgentHostByokModelsEnabledDefault, tags: ['experimental', 'advanced'], experiment: { mode: 'startup' }, agentHost: { key: AgentHostByokModelsEnabledConfigKey, scope: AgentHostConfigurationSyncScope.Local }, diff --git a/src/vs/platform/agentHost/common/agentHostTelemetry.ts b/src/vs/platform/agentHost/common/agentHostTelemetry.ts index 287a363cca1e51..71a2744d013912 100644 --- a/src/vs/platform/agentHost/common/agentHostTelemetry.ts +++ b/src/vs/platform/agentHost/common/agentHostTelemetry.ts @@ -127,6 +127,16 @@ export interface ICodexAccountTelemetryContext { readonly chatgptPlanTier?: 'free' | 'go' | 'plus' | 'pro' | 'business' | 'enterprise' | 'edu' | 'unknown'; readonly chatgptWeeklyQuotaState: 'available' | 'unavailable' | 'missing' | 'nonWeekly' | 'stale' | 'expired' | 'invalid'; readonly chatgptWeeklyUsedPercentBucket?: number; + readonly chatgptFiveHourQuotaState: 'available' | 'unavailable' | 'missing' | 'stale' | 'expired' | 'invalid'; + readonly chatgptFiveHourUsedPercentBucket?: number; +} + +export type CodexModelProvider = 'openai' | 'copilot' | 'other' | 'unknown'; + +export interface IAgentTurnTelemetryCorrelation { + readonly agentSessionId: string; + readonly chatSessionId: string; + readonly turnId: string; } /** Provider-owned, immutable context captured without I/O when a turn starts. */ diff --git a/src/vs/platform/agentHost/common/agentServerTools.ts b/src/vs/platform/agentHost/common/agentServerTools.ts index 2790a54209f797..c0d728bee105b7 100644 --- a/src/vs/platform/agentHost/common/agentServerTools.ts +++ b/src/vs/platform/agentHost/common/agentServerTools.ts @@ -15,6 +15,8 @@ export interface IAgentServerToolDefinition extends ToolDefinition { * throwaway surfaces do not pay for session-management tooling. */ readonly enabledForEphemeralSessions?: boolean; + /** Available to owning chats, but not their native or protocol subagents. */ + readonly topLevelChatOnly?: boolean; /** Defers the tool behind discovery when supported; `false` keeps it eager. Omit to preserve provider defaults. */ readonly deferLoading?: boolean; } @@ -41,8 +43,8 @@ export interface IAgentServerToolHost { getDefinitionsForSession(sessionUri: URI): readonly IAgentServerToolDefinition[]; /** Routable server-tool names, including materialized tools retained by sessions after root settings change. */ readonly toolNames: readonly string[]; - /** Advertises all server tools on the session's `serverTools`. */ - advertise(sessionUri: URI): void; + /** Advertises all server tools on the owning session, accepting a session or chat URI. */ + advertise(sessionOrChatUri: URI): void; /** * Whether {@link toolName} can ever prompt for confirmation, independent of * any session's current state. Providers use this to decide up front which diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 72f57ecb90656a..3861d098da2194 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -115,6 +115,10 @@ export const AgentHostSystemProxyEnabledSettingId = 'chat.agentHost.systemProxy. /** Configuration key controlling the GitHub MCP server in agent-host sessions. */ export const AgentHostGitHubMcpServerEnabledSettingId = 'chat.agentHost.githubMcpServer.enabled'; +/** Configuration keys controlling automatic session and chat title generation. */ +export const AgentHostActiveAgentTitleGenerationSettingId = 'chat.agentHost.experimental.activeAgentTitleGeneration'; +export const AgentHostDeferredTitleGenerationSettingId = 'chat.agentHost.experimental.deferredTitleGeneration'; + /** Configuration key enabling cached MCP tool routing and relevance-based authentication prompts. */ export const AgentHostMcpToolRoutingEnabledSettingId = 'chat.agentHost.experimental.mcpToolRouting'; @@ -281,7 +285,8 @@ export const CodexPreferAgentHostEditorSettingId = 'chat.editor.codex.preferAgen export function affectsAgentHostProviderPreference(event: IConfigurationChangeEvent, isSessionsWindow: boolean): boolean { return event.affectsConfiguration(AgentHostClaudeAgentEnabledSettingId) - || event.affectsConfiguration(isSessionsWindow ? AgentHostCodexAgentEnabledSettingId : CodexPreferAgentHostEditorSettingId); + || event.affectsConfiguration(AgentHostCodexAgentEnabledSettingId) + || (!isSessionsWindow && event.affectsConfiguration(CodexPreferAgentHostEditorSettingId)); } export function shouldSurfaceLocalAgentHostProvider(provider: AgentProvider, configurationService: IConfigurationService, isSessionsWindow: boolean): boolean { @@ -289,7 +294,8 @@ export function shouldSurfaceLocalAgentHostProvider(provider: AgentProvider, con case CLAUDE_AGENT_PROVIDER_ID: return configurationService.getValue(AgentHostClaudeAgentEnabledSettingId) !== false; case CODEX_AGENT_PROVIDER_ID: - return configurationService.getValue(isSessionsWindow ? AgentHostCodexAgentEnabledSettingId : CodexPreferAgentHostEditorSettingId) === true; + return configurationService.getValue(AgentHostCodexAgentEnabledSettingId) === true + && (isSessionsWindow || configurationService.getValue(CodexPreferAgentHostEditorSettingId) === true); default: return true; } @@ -797,8 +803,29 @@ export interface IConnectionTrackerService { * Narrow renderer-to-local-agent-host control surface. All stateful agent * operations travel over {@link AgentHostIpcChannels.Protocol}. */ +export interface IMissionControlOptions { + readonly baseUrl: string; + readonly accountId: string; + readonly credential: string; + readonly roots: readonly string[]; + readonly live?: boolean; + readonly requireConnectionBinding?: boolean; +} + +/** Stateless sealing on trusted local IPC; this surface is not exposed through AHP. */ +export interface IMissionControlCredentialSealingRequest { + readonly token: string; + readonly resource: string; + readonly key: { readonly key_id: string; readonly use: 'auth-token' | 'mcp-auth-token'; readonly algorithm: string; readonly public_key: string }; + readonly challenge?: string; +} + export interface IAgentHostManagementService { readonly _serviceBrand: undefined; + /** Opt-in Mission Control ingress configured through trusted local IPC. */ + configureMissionControl(options: IMissionControlOptions | undefined, withdrawingAccountId?: string): Promise; + sealMissionControlCredential(request: IMissionControlCredentialSealingRequest): Promise; + getMissionControlEnvironmentId(): Promise; /** * Local-only compatibility path for session fields not yet represented by @@ -1036,8 +1063,9 @@ export interface IAgentService { * actions, or {@link ROOT_STATE_URI} for root actions). Strings are used * rather than {@link URI} objects so that authority-less scheme URIs * like `ahp-root://` survive the wire format without normalization. + * Queued dispatches return a promise that settles after application or rejection. */ - dispatchAction(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction | ClientAutomationAction | ClientAutomationRunAction, clientId: string, clientSeq: number, clientContext?: IAgentHostClientTelemetryContext): void; + dispatchAction(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction | ClientAutomationAction | ClientAutomationRunAction, clientId: string, clientSeq: number, clientContext?: IAgentHostClientTelemetryContext): void | Promise; /** * List the contents of a directory on the agent host's filesystem. @@ -1289,6 +1317,10 @@ export const IAgentHostService = createDecorator('agentHostSe export interface IAgentHostService extends IAgentConnection { readonly _serviceBrand: undefined; + /** Available only through the local native Agent Host management connection. */ + configureMissionControl?(options: IMissionControlOptions | undefined, withdrawingAccountId?: string): Promise; + sealMissionControlCredential?(request: IMissionControlCredentialSealingRequest): Promise; + getMissionControlEnvironmentId?(): Promise; readonly onAgentHostExit: Event; readonly onAgentHostStart: Event; diff --git a/src/vs/platform/agentHost/common/cloudSandboxAgentHost.ts b/src/vs/platform/agentHost/common/cloudSandboxAgentHost.ts index de0677e3b9db1d..520ded7be1934b 100644 --- a/src/vs/platform/agentHost/common/cloudSandboxAgentHost.ts +++ b/src/vs/platform/agentHost/common/cloudSandboxAgentHost.ts @@ -219,6 +219,17 @@ export interface ICloudSandboxEnvironment { /** The copilotd **host** version (e.g. `0.6.3`), not the AHP wire protocol version. */ readonly ahp_version?: string; }; + /** Public recipient keys obtained through authenticated MC HTTPS, not through the relay. */ + readonly encryption_keys?: readonly IHostEncryptionKey[]; +} + +/** Credential-free discovery metadata for a Mission Control environment. */ +export interface IMissionControlEnvironment { + readonly id: string; + /** Open vocabulary; only online permits attachment without a warning. */ + readonly status: string; + readonly kind: string; + readonly name: string; } /** Identifies which sandbox environment/session credentials are being minted for. */ @@ -272,6 +283,10 @@ export interface ICloudSandboxApiService { * Control only discovers by attempting the resume behind `/connect`. */ getEnvironment(environmentId: string, token: CancellationToken): Promise; + /** Lists the authenticated account's environments without attaching or provisioning compute. */ + listEnvironments(token: CancellationToken, options?: { readonly refresh?: boolean }): Promise; + /** Returns fresh credential-free inventory for the current account without issuing a request. */ + getCachedEnvironments(): readonly IMissionControlEnvironment[] | undefined; /** Enumerate sandbox sessions, optionally returning changes since the last successful scan. */ listSessions(token: CancellationToken, options?: { readonly incremental?: boolean }): Promise; @@ -352,12 +367,16 @@ export const ICloudSandboxAgentHostService = createDecorator`). */ readonly environmentId: string; /** The cloud session/task id this connection is for, used for token minting. */ readonly sessionId?: string; /** Human-readable display name for the connection. */ readonly name: string; + /** Local identity that owns this staged user-local connection; never sent to MC. */ + readonly accountKey?: string; /** Caller provenance, not a claim about warm or cold compute. */ readonly connectionSource?: 'created' | 'existing'; } @@ -382,9 +401,8 @@ export interface ICloudSandboxAgentHostService { * server-provided Retry-After delay. */ connect(options: ICloudSandboxConnectOptions, token: CancellationToken): Promise; - /** Disconnect a sandbox address and discard its staged credentials. */ - disconnect(address: string): Promise; + disconnect(addressOrEnvironmentId: string): Promise; /** * The sealed GitHub token for a live connection to the given environment, as minted by diff --git a/src/vs/platform/agentHost/common/codexAccountPlan.ts b/src/vs/platform/agentHost/common/codexAccountPlan.ts new file mode 100644 index 00000000000000..3981fd6eb6c39d --- /dev/null +++ b/src/vs/platform/agentHost/common/codexAccountPlan.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 type { ICodexAccountTelemetryContext } from './agentHostTelemetry.js'; + +const chatgptPlanTiers = { + free: 'free', + go: 'go', + plus: 'plus', + pro: 'pro', + prolite: 'pro', + team: 'business', + self_serve_business_prolite: 'business', + self_serve_business_usage_based: 'business', + business: 'business', + ent26: 'enterprise', + enterprise_cbp_automation: 'enterprise', + enterprise_cbp_usage_based: 'enterprise', + enterprise: 'enterprise', + edu: 'edu', + edu_plus: 'edu', + edu_pro: 'edu', + unknown: 'unknown', +} as const satisfies Record>; + +export function normalizeChatGPTPlanTier(planType: unknown): NonNullable { + return typeof planType === 'string' && Object.hasOwn(chatgptPlanTiers, planType) ? chatgptPlanTiers[planType as keyof typeof chatgptPlanTiers] : 'unknown'; +} + +export function isPaidChatGPTPlan(planType: string | undefined): boolean { + const tier = normalizeChatGPTPlanTier(planType); + return tier !== 'free' && tier !== 'unknown'; +} diff --git a/src/vs/platform/agentHost/common/codexModelSelection.ts b/src/vs/platform/agentHost/common/codexModelSelection.ts new file mode 100644 index 00000000000000..aac6f04786c5ce --- /dev/null +++ b/src/vs/platform/agentHost/common/codexModelSelection.ts @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { CodexModelProvider } from './agentHostTelemetry.js'; +import type { ModelSelection } from './state/sessionState.js'; + +const CODEX_MODEL_SELECTION_PREFIX = '@provider='; +const CODEX_COPILOT_MODEL_PROVIDER = 'vscode-proxy'; + +export function toCodexModelSelectionId(modelProvider: string, modelId: string): string { + return `${CODEX_MODEL_SELECTION_PREFIX}${encodeURIComponent(modelProvider)}:${encodeURIComponent(modelId)}`; +} + +export function parseCodexModelSelection(selection: ModelSelection): { readonly modelProvider: string; readonly modelId: string } { + if (!selection.id.startsWith(CODEX_MODEL_SELECTION_PREFIX)) { + return { modelProvider: CODEX_COPILOT_MODEL_PROVIDER, modelId: selection.id }; + } + const separator = selection.id.indexOf(':', CODEX_MODEL_SELECTION_PREFIX.length); + if (separator < CODEX_MODEL_SELECTION_PREFIX.length) { + return { modelProvider: CODEX_COPILOT_MODEL_PROVIDER, modelId: selection.id }; + } + try { + return { + modelProvider: decodeURIComponent(selection.id.slice(CODEX_MODEL_SELECTION_PREFIX.length, separator)), + modelId: decodeURIComponent(selection.id.slice(separator + 1)), + }; + } catch { + return { modelProvider: CODEX_COPILOT_MODEL_PROVIDER, modelId: selection.id }; + } +} + +/** Bounds the provider actually used at dispatch; legacy model IDs are not evidence. */ +export function toCodexModelProvider(provider: string | undefined): CodexModelProvider { + return provider === 'openai' ? 'openai' : provider === 'vscode-proxy' ? 'copilot' : provider ? 'other' : 'unknown'; +} diff --git a/src/vs/platform/agentHost/common/fileEditAttribution.ts b/src/vs/platform/agentHost/common/fileEditAttribution.ts index 45389949543bdc..7d41b0905afd94 100644 --- a/src/vs/platform/agentHost/common/fileEditAttribution.ts +++ b/src/vs/platform/agentHost/common/fileEditAttribution.ts @@ -50,6 +50,7 @@ export type AttributedToolResultFileEditContent = ToolResultFileEditContent & { }; export interface IAgentEditAttribution { + readonly provider?: string; readonly sessionUri: string; /** Originating chat channel, when known; separate from the owning session. */ readonly chatUri?: string; diff --git a/src/vs/platform/agentHost/common/meta/agentHostSessionUrisMeta.ts b/src/vs/platform/agentHost/common/meta/agentHostSessionUrisMeta.ts new file mode 100644 index 00000000000000..3dabb4dff6eb30 --- /dev/null +++ b/src/vs/platform/agentHost/common/meta/agentHostSessionUrisMeta.ts @@ -0,0 +1,6 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export { AgentHostSessionUrisCapabilityMetaKey, AgentHostNativeImplementationMetaKey, isNativeAgentHost, supportsAgentHostSessionUris } from './vscode/agentHostSessionUrisMeta.js'; diff --git a/src/vs/platform/agentHost/common/meta/agentImageGenerationMeta.ts b/src/vs/platform/agentHost/common/meta/agentImageGenerationMeta.ts new file mode 100644 index 00000000000000..bb81b3cbf0a787 --- /dev/null +++ b/src/vs/platform/agentHost/common/meta/agentImageGenerationMeta.ts @@ -0,0 +1,6 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export { imageGenerationToolMetaKey, parseImageGenerationToolMetadata, readImageGenerationToolMetadata, type IImageGenerationToolMetadata } from './vscode/agentImageGenerationMeta.js'; diff --git a/src/vs/platform/agentHost/common/meta/codexAccount.ts b/src/vs/platform/agentHost/common/meta/codexAccount.ts index f465484b3ef970..6e6f68c3269032 100644 --- a/src/vs/platform/agentHost/common/meta/codexAccount.ts +++ b/src/vs/platform/agentHost/common/meta/codexAccount.ts @@ -4,4 +4,4 @@ *--------------------------------------------------------------------------------------------*/ export type { ICodexProfileImageReference, ICodexAccountRateLimitInfo, ICodexAccountInfo } from './vscode/codexAccount.js'; -export { CODEX_ACCOUNT_META_KEY, CODEX_ACCOUNT_SIGN_IN_REQUEST_KEY, CODEX_ACCOUNT_SIGN_OUT_REQUEST_KEY, CODEX_PROFILE_IMAGE_SCHEME, MAX_CODEX_PROFILE_IMAGE_BYTES, readCodexAccountInfo } from './vscode/codexAccount.js'; +export { CODEX_ACCOUNT_REFRESH_REQUEST_KEY, CODEX_ACCOUNT_META_KEY, CODEX_ACCOUNT_SIGN_IN_REQUEST_KEY, CODEX_ACCOUNT_SIGN_OUT_REQUEST_KEY, CODEX_PROFILE_IMAGE_SCHEME, MAX_CODEX_PROFILE_IMAGE_BYTES, readCodexAccountInfo } from './vscode/codexAccount.js'; diff --git a/src/vs/platform/agentHost/common/meta/codexSessionModel.ts b/src/vs/platform/agentHost/common/meta/codexSessionModel.ts new file mode 100644 index 00000000000000..e861fbf49b3231 --- /dev/null +++ b/src/vs/platform/agentHost/common/meta/codexSessionModel.ts @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { ModelSelection } from '../state/protocol/state.js'; +import { parseCodexModelSelection, toCodexModelSelectionId } from '../codexModelSelection.js'; + +/** Namespaced {@link SessionSummary._meta} slot carrying a Codex provider model. */ +export const CODEX_SESSION_MODEL_META_KEY = 'vscode.codex.modelSelection'; + +interface IHasCodexSessionModelMeta { + readonly _meta?: Record; +} + +interface ICodexSessionModelMeta { + readonly id: string; +} + +/** + * Reads a canonical Codex provider model from session metadata. + * + * Legacy model ids do not identify their provider, so they deliberately fail + * closed rather than being interpreted as Copilot models by the legacy parser. + */ +export function readCodexSessionModel(source: IHasCodexSessionModelMeta | undefined): ModelSelection | undefined { + const value = source?._meta?.[CODEX_SESSION_MODEL_META_KEY]; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + const id = (value as Partial).id; + if (typeof id !== 'string' || id.length === 0) { + return undefined; + } + const parsed = parseCodexModelSelection({ id }); + return parsed.modelProvider.length > 0 + && parsed.modelId.length > 0 + && toCodexModelSelectionId(parsed.modelProvider, parsed.modelId) === id + ? { id } + : undefined; +} + +/** Adds or removes the validated Codex provider model metadata slot. */ +export function withCodexSessionModel(meta: Record | undefined, model: ModelSelection | undefined): Record | undefined { + const validated = model ? readCodexSessionModel({ _meta: { [CODEX_SESSION_MODEL_META_KEY]: { id: model.id } } }) : undefined; + if (validated) { + return { ...meta, [CODEX_SESSION_MODEL_META_KEY]: { id: validated.id } satisfies ICodexSessionModelMeta }; + } + if (!meta || !Object.hasOwn(meta, CODEX_SESSION_MODEL_META_KEY)) { + return meta; + } + const next = { ...meta }; + delete next[CODEX_SESSION_MODEL_META_KEY]; + return Object.keys(next).length > 0 ? next : undefined; +} diff --git a/src/vs/platform/agentHost/common/meta/mcpCustomizationMeta.ts b/src/vs/platform/agentHost/common/meta/mcpCustomizationMeta.ts index 3d849ec889c898..2db68df127f893 100644 --- a/src/vs/platform/agentHost/common/meta/mcpCustomizationMeta.ts +++ b/src/vs/platform/agentHost/common/meta/mcpCustomizationMeta.ts @@ -4,4 +4,4 @@ *--------------------------------------------------------------------------------------------*/ export type { McpServerSource } from './vscode/mcpCustomizationMeta.js'; -export { readMcpServerDisplayName, readMcpServerSource, withMcpServerDisplayNameMeta, withMcpServerSourceMeta } from './vscode/mcpCustomizationMeta.js'; +export { readMcpServerControllingSetting, readMcpServerDisplayName, readMcpServerSource, readMcpServerSourcePlugin, withMcpServerControllingSettingMeta, withMcpServerDisplayNameMeta, withMcpServerSourceMeta, withMcpServerSourcePluginMeta } from './vscode/mcpCustomizationMeta.js'; diff --git a/src/vs/platform/agentHost/common/meta/relayConnectionMeta.ts b/src/vs/platform/agentHost/common/meta/relayConnectionMeta.ts new file mode 100644 index 00000000000000..47f8d0e739bbe6 --- /dev/null +++ b/src/vs/platform/agentHost/common/meta/relayConnectionMeta.ts @@ -0,0 +1,15 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** Connection-scoped relay metadata; absence preserves the ordinary host behavior. */ +export function isPassiveRelayConnection(result: { readonly _meta?: Record } | undefined): boolean { + return result?._meta?.['copilot.passive'] === true; +} + +/** A positive advertised inbound-idle window, not the client's response timeout. */ +export function readRelayKeepAliveTimeout(result: { readonly _meta?: Record } | undefined): number | undefined { + const value = result?._meta?.['copilot.keepAliveTimeoutMs']; + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : undefined; +} diff --git a/src/vs/platform/agentHost/common/meta/vscode/agentHostSessionUrisMeta.ts b/src/vs/platform/agentHost/common/meta/vscode/agentHostSessionUrisMeta.ts new file mode 100644 index 00000000000000..6fd6599c880f8c --- /dev/null +++ b/src/vs/platform/agentHost/common/meta/vscode/agentHostSessionUrisMeta.ts @@ -0,0 +1,34 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { InitializeParams, InitializeResult, ReconnectParams } from '../../state/protocol/common/commands.js'; +import { isObject } from '../../../../../base/common/types.js'; +import { ROOT_STATE_URI, type RootState } from '../../state/sessionState.js'; + +export const AgentHostSessionUrisCapabilityMetaKey = 'vscode.ahpSessionUris'; +export const AgentHostNativeImplementationMetaKey = 'vscode.agentHost'; + +/** Older native hosts identify their hosting VS Code CLI through the root's hostBuild payload. */ +export function isNativeAgentHost(result: InitializeResult | undefined, root?: RootState): boolean { + if (result?._meta?.[AgentHostNativeImplementationMetaKey] === true) { + return true; + } + const rootSnapshot = result?.snapshots?.find(snapshot => snapshot.resource === ROOT_STATE_URI); + const state: unknown = root ?? rootSnapshot?.state; + if (!isMetadataRecord(state) || !isMetadataRecord(state._meta)) { + return false; + } + const build = state._meta.hostBuild; + return isMetadataRecord(build) && typeof build.version === 'string' && build.version.length > 0; +} + +function isMetadataRecord(value: unknown): value is Record { + return isObject(value); +} + +/** Declares support for mixed immutable session resources with a separate provider identity. */ +export function supportsAgentHostSessionUris(peer: Pick | undefined): boolean { + return peer?._meta?.[AgentHostSessionUrisCapabilityMetaKey] === true; +} diff --git a/src/vs/platform/agentHost/common/meta/vscode/agentImageGenerationMeta.ts b/src/vs/platform/agentHost/common/meta/vscode/agentImageGenerationMeta.ts new file mode 100644 index 00000000000000..1ea522ac884723 --- /dev/null +++ b/src/vs/platform/agentHost/common/meta/vscode/agentImageGenerationMeta.ts @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { isObject } from '../../../../../base/common/types.js'; +import type { IAgentMetadataSource } from '../metadata.js'; + +export const imageGenerationToolMetaKey = 'vscode.imageGeneration'; + +/** The image engine requested by this tool, not the conversation model or a claimed serving model. */ +export interface IImageGenerationToolMetadata { + readonly requestedModel: { + readonly id: string; + readonly name?: string; + }; +} + +export function parseImageGenerationToolMetadata(value: unknown): IImageGenerationToolMetadata | undefined { + if (!isObject(value)) { + return undefined; + } + const requestedModel = (value as Record).requestedModel; + if (!isObject(requestedModel)) { + return undefined; + } + const model = requestedModel as Record; + const id = model.id; + if (typeof id !== 'string' || !id.trim()) { + return undefined; + } + const name = typeof model.name === 'string' ? model.name.trim() : undefined; + return { requestedModel: { id, ...(name ? { name } : {}) } }; +} + +export function readImageGenerationToolMetadata(source: IAgentMetadataSource): IImageGenerationToolMetadata | undefined { + return parseImageGenerationToolMetadata(source._meta?.[imageGenerationToolMetaKey]); +} diff --git a/src/vs/platform/agentHost/common/meta/vscode/agentToolCallMeta.ts b/src/vs/platform/agentHost/common/meta/vscode/agentToolCallMeta.ts index 096b44ea359877..5bc36ea5d09822 100644 --- a/src/vs/platform/agentHost/common/meta/vscode/agentToolCallMeta.ts +++ b/src/vs/platform/agentHost/common/meta/vscode/agentToolCallMeta.ts @@ -5,6 +5,7 @@ import type { Mutable } from '../../../../../base/common/types.js'; import { hasAgentMetadata } from '../metadata.js'; +import { imageGenerationToolMetaKey, readImageGenerationToolMetadata, type IImageGenerationToolMetadata } from './agentImageGenerationMeta.js'; import { ISandboxNetworkRestrictions, isSandboxNetworkRestrictions } from '../../../../sandbox/common/sandboxSettingsResolutionHelper.js'; /** Anything carrying a tool call's `_meta` bag (persisted state or wire actions). */ @@ -19,6 +20,7 @@ interface IHasToolCallMeta { * wrong-typed values. */ export interface IToolCallMeta { + readonly [imageGenerationToolMetaKey]?: IImageGenerationToolMetadata; /** Trusted Copilot host snapshot for integrated-browser client tools. Absent for other tools and harnesses. */ readonly 'vscode.copilotSandboxNetworkRestrictions'?: ISandboxNetworkRestrictions; readonly 'agentHost.sandboxBypass'?: boolean; @@ -65,6 +67,7 @@ export type AgentFusionPhaseStatus = typeof fusionPhaseStatuses[number]; const knownFusionPhaseStatuses: ReadonlySet = new Set(fusionPhaseStatuses); const toolCallMetaKeys = [ + imageGenerationToolMetaKey, 'vscode.copilotSandboxNetworkRestrictions', 'agentHost.sandboxBypass', 'toolKind', 'language', 'subagentDescription', 'subagentAgentName', 'subagentChatUri', 'mcpServerName', 'mcpToolName', 'autoApproveBySetting', 'autoApproveRuleResolvable', 'toolSearchCandidates', 'progressMessage', 'fusionPhase', @@ -184,6 +187,8 @@ export function readToolCallMeta(source: IHasToolCallMeta): IToolCallMeta { 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; } + const imageGeneration = readImageGenerationToolMetadata(source); + if (imageGeneration) { result[imageGenerationToolMetaKey] = imageGeneration; } const fusionPhase = readFusionPhase(meta.fusionPhase); if (fusionPhase) { result.fusionPhase = fusionPhase; } if (typeof meta.autoApproveBySetting === 'boolean') { result.autoApproveBySetting = meta.autoApproveBySetting; } diff --git a/src/vs/platform/agentHost/common/meta/vscode/codexAccount.ts b/src/vs/platform/agentHost/common/meta/vscode/codexAccount.ts index 5351c62e6874a3..bf6e719efcff3e 100644 --- a/src/vs/platform/agentHost/common/meta/vscode/codexAccount.ts +++ b/src/vs/platform/agentHost/common/meta/vscode/codexAccount.ts @@ -6,6 +6,7 @@ import { URI } from '../../../../../base/common/uri.js'; import type { RootState } from '../../state/protocol/state.js'; +export const CODEX_ACCOUNT_REFRESH_REQUEST_KEY = 'vscode.codexAccount.refreshRequest'; export const CODEX_ACCOUNT_META_KEY = 'vscode.codexAccount'; export const CODEX_ACCOUNT_SIGN_IN_REQUEST_KEY = 'vscode.codexAccount.signInRequest'; export const CODEX_ACCOUNT_SIGN_OUT_REQUEST_KEY = 'vscode.codexAccount.signOutRequest'; @@ -42,6 +43,8 @@ export interface ICodexAccountInfo { /** Summary window retained for clients that only display one limit. */ readonly rateLimit?: ICodexAccountRateLimitInfo; readonly rateLimits?: readonly ICodexAccountRateLimitInfo[]; + /** Unix milliseconds at which rate limits were observed, never the publication time. */ + readonly observedAt?: number; readonly authUrl?: string; readonly authUrlNonce?: string; } @@ -62,6 +65,7 @@ export function readCodexAccountInfo(state: RootState | undefined): ICodexAccoun planType: typeof account.planType === 'string' ? account.planType : undefined, profileImage: readProfileImageReference(account.profileImage), requiresOpenaiAuth: typeof account.requiresOpenaiAuth === 'boolean' ? account.requiresOpenaiAuth : undefined, + observedAt: typeof account.observedAt === 'number' && Number.isFinite(account.observedAt) && account.observedAt >= 0 ? account.observedAt : undefined, rateLimit: readRateLimitInfo(account.rateLimit), rateLimits: Array.isArray(account.rateLimits) ? account.rateLimits.map(readRateLimitInfo).filter(limit => limit !== undefined) : undefined, authUrl: typeof account.authUrl === 'string' ? account.authUrl : undefined, diff --git a/src/vs/platform/agentHost/common/meta/vscode/mcpCustomizationMeta.ts b/src/vs/platform/agentHost/common/meta/vscode/mcpCustomizationMeta.ts index 5b854324187ee6..5d7add44bc0128 100644 --- a/src/vs/platform/agentHost/common/meta/vscode/mcpCustomizationMeta.ts +++ b/src/vs/platform/agentHost/common/meta/vscode/mcpCustomizationMeta.ts @@ -8,6 +8,8 @@ import type { McpServerCustomization } from '../../state/protocol/state.js'; const sourceKey = 'agentHost.mcpServerSource'; const displayNameKey = 'vscode.mcpServerDisplayName'; const maxDisplayNameLength = 512; +const sourcePluginKey = 'agentHost.mcpServerSourcePlugin'; +const controllingSettingKey = 'vscode.mcpServerControllingSetting'; export type McpServerSource = | 'user' // Defined in user-level configuration. @@ -57,3 +59,52 @@ function normalizeMcpServerDisplayName(value: unknown): string | undefined { const trimmed = value.trim(); return trimmed.length > 0 && trimmed.length <= maxDisplayNameLength ? trimmed : undefined; } + +/** + * Reads the plugin the runtime reports as the source of the server's configuration. The + * plugin may be one the client never published, such as a plugin bundled with the agent. + */ +export function readMcpServerSourcePlugin(customization: McpServerCustomization | undefined): string | undefined { + return readNonEmptyString(customization?._meta?.[sourcePluginKey]); +} + +/** + * Records the runtime-reported source plugin in an open metadata bag. An absent plugin removes a + * previously recorded one, because it describes the current configuration; every other entry is preserved. + */ +export function withMcpServerSourcePluginMeta(meta: Record | undefined, sourcePlugin: string | undefined): Record | undefined { + return withMetaEntry(meta, sourcePluginKey, sourcePlugin); +} + +/** + * Reads the VS Code setting a VS Code agent host declares as controlling whether it includes a server. + * Only the host that owns the server can declare this; clients must not infer it from the server's name, + * because another host can publish a server with the same name that the setting cannot control. + */ +export function readMcpServerControllingSetting(customization: McpServerCustomization | undefined): string | undefined { + return readNonEmptyString(customization?._meta?.[controllingSettingKey]); +} + +/** + * Records the VS Code setting that controls whether the host includes a server. An absent setting removes a + * previously recorded one; every other entry is preserved. + */ +export function withMcpServerControllingSettingMeta(meta: Record | undefined, settingId: string | undefined): Record | undefined { + return withMetaEntry(meta, controllingSettingKey, settingId); +} + +function readNonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value : undefined; +} + +function withMetaEntry(meta: Record | undefined, key: string, value: string | undefined): Record | undefined { + if (value === undefined) { + if (!meta || !Object.hasOwn(meta, key)) { + return meta; + } + const remaining = { ...meta }; + delete remaining[key]; + return Object.keys(remaining).length > 0 ? remaining : undefined; + } + return meta?.[key] === value ? meta : { ...(meta ?? {}), [key]: value }; +} diff --git a/src/vs/platform/agentHost/common/openSessionLink.ts b/src/vs/platform/agentHost/common/openSessionLink.ts index 49aebec7841eb5..a31048d68dd808 100644 --- a/src/vs/platform/agentHost/common/openSessionLink.ts +++ b/src/vs/platform/agentHost/common/openSessionLink.ts @@ -90,14 +90,21 @@ export function isSendMessageTool(toolName: string): boolean { * ecosystem-wide invariant (an absent chat id already means "the default chat"), * so it is enforced here once rather than at each call site. */ -export function buildOpenSessionLinkUri(backendSession: URI | string, chatId?: string, turnId?: string): string { - const provider = AgentSession.provider(backendSession); +export function buildOpenSessionLinkUri(backendSession: URI | string, chatId?: string, turnId?: string, connectionAuthority?: string): string { + const session = typeof backendSession === 'string' ? URI.parse(backendSession) : backendSession; + const provider = session.scheme; const rawId = AgentSession.id(backendSession); if (!provider) { throw new Error(`Cannot build open-session link: missing provider in ${backendSession.toString()}`); } const base = URI.from({ scheme: AGENT_HOST_SESSION_LINK_SCHEME, authority: provider, path: `/${rawId}` }).toString(); const query: string[] = []; + if (connectionAuthority) { + query.push(`connectionAuthority=${encodeURIComponent(connectionAuthority)}`); + } + if (session.authority || session.query || session.fragment) { + query.push(`session=${encodeURIComponent(session.toString())}`); + } if (chatId && chatId !== DEFAULT_CHAT_ID) { query.push(`chat=${encodeURIComponent(chatId)}`); } @@ -112,11 +119,14 @@ export function buildOpenSessionLinkUri(backendSession: URI | string, chatId?: s * * Shape: `://agents/agent-host-session//[/chat/]`. */ -export function buildExternalOpenSessionLinkUri(productUrlProtocol: string, backendSession: URI | string, chatId?: string, turnId?: string): string { - const sessionLink = buildOpenSessionLinkUri(backendSession); +export function buildExternalOpenSessionLinkUri(productUrlProtocol: string, backendSession: URI | string, chatId?: string, turnId?: string, connectionAuthority?: string): string { + const sessionLinkText = buildOpenSessionLinkUri(backendSession, undefined, undefined, connectionAuthority); + const link = URI.parse(sessionLinkText); + const sessionLink = link.with({ query: '' }).toString(); const encodedTarget = sessionLink.slice(`${AGENT_HOST_SESSION_LINK_SCHEME}://`.length); const chatPath = chatId && chatId !== DEFAULT_CHAT_ID ? `${AGENT_HOST_CHAT_LINK_PATH_SEGMENT}${encodeURIComponent(encodeURIComponent(chatId))}` : ''; - const query = turnId ? `?turn=${encodeURIComponent(turnId)}` : ''; + const queryParts = [sessionLinkText.split('?')[1] ?? '', turnId ? `turn=${encodeURIComponent(turnId)}` : ''].filter(Boolean); + const query = queryParts.length ? `?${queryParts.join('&')}` : ''; return `${productUrlProtocol}://${AGENTS_AUTHORITY}${AGENT_HOST_SESSION_LINK_PATH_PREFIX}${encodedTarget}${chatPath}${query}`; } @@ -173,10 +183,17 @@ export function parseOpenSessionLinkUri(uri: URI | string): URI | undefined { if (parsed.scheme !== AGENT_HOST_SESSION_LINK_SCHEME || !parsed.authority) { return undefined; } + if (/(?:^|&)connectionAuthority=/.test(parsed.query) && !parseOpenSessionLinkConnectionAuthority(parsed)) { + return undefined; + } const rawId = parsed.path.replace(/^\//, ''); if (!rawId) { return undefined; } + const session = readOpenSessionLinkQueryParam(parsed, 'session'); + if (session) { + return URI.parse(session, true); + } return AgentSession.uri(parsed.authority, rawId); } @@ -199,6 +216,10 @@ export function parseOpenSessionLinkTurnId(uri: URI | string): string | undefine return readOpenSessionLinkQueryParam(uri, 'turn'); } +export function parseOpenSessionLinkConnectionAuthority(uri: URI | string): string | undefined { + return readOpenSessionLinkQueryParam(uri, 'connectionAuthority'); +} + function readOpenSessionLinkQueryParam(uri: URI | string, name: string): string | undefined { const parsed = typeof uri === 'string' ? URI.parse(uri) : uri; if (parsed.scheme !== AGENT_HOST_SESSION_LINK_SCHEME) { diff --git a/src/vs/platform/agentHost/common/pendingEditContentUri.ts b/src/vs/platform/agentHost/common/pendingEditContentUri.ts new file mode 100644 index 00000000000000..34cbfb1ec519a0 --- /dev/null +++ b/src/vs/platform/agentHost/common/pendingEditContentUri.ts @@ -0,0 +1,34 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { decodeHex, encodeHex, VSBuffer } from '../../../base/common/buffer.js'; +import { URI } from '../../../base/common/uri.js'; + +export const PENDING_EDIT_CONTENT_SCHEME = 'pending-edit-content'; + +/** Identifies transient proposed file contents without exposing them on the state tree. */ +export function buildPendingEditContentUri(sessionUri: string, toolCallId: string, filePath: string): URI { + return URI.from({ + scheme: PENDING_EDIT_CONTENT_SCHEME, + authority: encodeHex(VSBuffer.fromString(sessionUri)).toString(), + path: `/${encodeURIComponent(toolCallId)}/${encodeHex(VSBuffer.fromString(filePath))}`, + }); +} + +/** Reads the session binding from a canonical pending-edit content reference. */ +export function parsePendingEditContentUri(raw: string): { readonly sessionUri: string } | undefined { + try { + const uri = URI.parse(raw); + const segments = uri.path.split('/'); + if (uri.scheme !== PENDING_EDIT_CONTENT_SCHEME || uri.query || uri.fragment || segments.length !== 3 + || !segments[1] || !/^(?:[a-f0-9]{2})+$/i.test(uri.authority) || !/^(?:[a-f0-9]{2})+$/i.test(segments[2])) { + return undefined; + } + const sessionUri = decodeHex(uri.authority).toString(); + return sessionUri ? { sessionUri } : undefined; + } catch { + return undefined; + } +} diff --git a/src/vs/platform/agentHost/common/remoteAgentHostService.ts b/src/vs/platform/agentHost/common/remoteAgentHostService.ts index e0894744b66a5c..d460c6cc239a66 100644 --- a/src/vs/platform/agentHost/common/remoteAgentHostService.ts +++ b/src/vs/platform/agentHost/common/remoteAgentHostService.ts @@ -220,6 +220,8 @@ export interface IRemoteAgentHostWSLConnection { * on demand with freshly-minted, short-lived credentials. */ export interface IRemoteAgentHostCloudSandboxConnection { + /** User-local environments use the same MC transport but not sandbox-specific providers. */ + readonly environmentKind?: 'user-local'; readonly type: RemoteAgentHostEntryType.CloudSandbox; /** Synthesized display address: `cloudsandbox:`. */ readonly address: string; diff --git a/src/vs/platform/agentHost/common/serverToolNames.ts b/src/vs/platform/agentHost/common/serverToolNames.ts index 7190ae671547db..69fd7d1a930335 100644 --- a/src/vs/platform/agentHost/common/serverToolNames.ts +++ b/src/vs/platform/agentHost/common/serverToolNames.ts @@ -20,6 +20,7 @@ export const enum SessionServerToolName { ListSessions = 'list_sessions', GetCurrentSession = 'get_current_session', SetWorkspace = 'set_workspace', + IsolateSession = 'isolate_session', CreateSession = 'create_session', CreateChat = 'create_chat', RenameChat = 'rename_chat', diff --git a/src/vs/platform/agentHost/common/state/agentSubscription.ts b/src/vs/platform/agentHost/common/state/agentSubscription.ts index 3ba7486ed82eb4..f39b8dc75e1283 100644 --- a/src/vs/platform/agentHost/common/state/agentSubscription.ts +++ b/src/vs/platform/agentHost/common/state/agentSubscription.ts @@ -1315,6 +1315,10 @@ export class AgentSubscriptionManager extends Disposable { * {@link BaseAgentSubscription.beginSnapshotRefresh}. */ beginSnapshotRefresh(resource: URI): void { + if (isAhpRootChannel(resource.toString())) { + this._rootState.beginSnapshotRefresh(); + return; + } const entry = this._subscriptions.get(resource); if (entry) { entry.snapshotVersion++; @@ -1324,6 +1328,10 @@ export class AgentSubscriptionManager extends Disposable { /** Abandon a refresh started by {@link beginSnapshotRefresh}. */ cancelSnapshotRefresh(resource: URI): void { + if (isAhpRootChannel(resource.toString())) { + this._rootState.cancelSnapshotRefresh(); + return; + } this._subscriptions.get(resource)?.sub.cancelSnapshotRefresh(); } diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 815159df456001..fac318fddf2ccc 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -940,7 +940,7 @@ export function isDefaultChatUri(uri: ProtocolURI | ResourceURI): boolean { export function getSessionChatResource(state: Pick & { readonly chats: readonly Pick[] }, chatId: string): ProtocolURI | undefined { return chatId === DEFAULT_CHAT_ID ? state.defaultChat ?? state.chats.find(chat => isDefaultChatUri(chat.resource))?.resource - : state.chats.find(chat => parseChatUri(chat.resource)?.chatId === chatId)?.resource; + : state.chats.find(chat => (parseChatUri(chat.resource)?.chatId ?? chat.resource.toString()) === chatId)?.resource; } /** diff --git a/src/vs/platform/agentHost/common/state/sessionTransport.ts b/src/vs/platform/agentHost/common/state/sessionTransport.ts index 4d2ee4dcf6c06d..af0c5d10586b2c 100644 --- a/src/vs/platform/agentHost/common/state/sessionTransport.ts +++ b/src/vs/platform/agentHost/common/state/sessionTransport.ts @@ -14,6 +14,7 @@ import { Event } from '../../../../base/common/event.js'; import { IDisposable } from '../../../../base/common/lifecycle.js'; import type { AgentHostClientConnectionKind, AgentHostTransportKind } from '../agentHostTelemetry.js'; import type { ProtocolMessage, AhpServerNotification, JsonRpcNotification, JsonRpcParseErrorResponse, JsonRpcResponse, JsonRpcRequest } from './sessionProtocol.js'; +import type { AuthenticateParams } from '../agent.js'; /** Machine-readable reasons a transport cannot be reconnected. */ export const enum AgentHostTransportFailureReason { @@ -39,6 +40,14 @@ export interface ITransportCloseDetails { * serialization, framing, and connection management. */ export interface IProtocolTransport extends IDisposable { + /** Trusted lane identity assigned by a relay server, never supplied by AHP. */ + readonly relayClientId?: string; + /** Trusted read-only designation assigned by a relay server. */ + readonly relayPassive?: boolean; + readonly relayHandshakeMeta?: Record; + /** Owner-validated identity for the current relay handshake, independent of transport access. */ + readonly relayAuthenticated?: boolean; + relayAuthenticate?(params: AuthenticateParams): Promise; /** Diagnostic metadata can arrive after onClose has already reported a transport failure. */ readonly onDidCloseDetails?: Event; /** Physical transport accepted by the agent host. */ diff --git a/src/vs/platform/agentHost/common/titleGenerationConfiguration.ts b/src/vs/platform/agentHost/common/titleGenerationConfiguration.ts new file mode 100644 index 00000000000000..957a512a4d4c33 --- /dev/null +++ b/src/vs/platform/agentHost/common/titleGenerationConfiguration.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as nls from '../../../nls.js'; +import { ConfigurationScope, IConfigurationPropertySchema } from '../../configuration/common/configurationRegistry.js'; +import product from '../../product/common/product.js'; +import { AgentHostActiveAgentTitleGenerationConfigKey, AgentHostDeferredTitleGenerationConfigKey } from './agentHostSchema.js'; +import { AgentHostActiveAgentTitleGenerationSettingId, AgentHostDeferredTitleGenerationSettingId } from './agentService.js'; + +export const titleGenerationConfigurationProperties = { + [AgentHostActiveAgentTitleGenerationSettingId]: { + type: 'boolean', + description: nls.localize('chat.agentHost.experimental.activeAgentTitleGeneration', "When enabled, the active agent names new sessions and chats using rename tools. When disabled, a utility model generates titles immediately. Deferred title generation takes precedence. Changes apply to new sessions; existing sessions and their chats retain their strategy."), + default: product.quality !== 'stable', + scope: ConfigurationScope.APPLICATION, + tags: ['experimental', 'advanced'], + experiment: { mode: 'auto' }, + agentHost: { key: AgentHostActiveAgentTitleGenerationConfigKey }, + }, + [AgentHostDeferredTitleGenerationSettingId]: { + type: 'boolean', + description: nls.localize('chat.agentHost.experimental.deferredTitleGeneration', "Seed titles immediately and refine them in the background if the first response turn completes successfully, without asking the active agent to name chats. Explicit rename tools remain available. Overrides active agent title generation for new sessions; existing sessions and their chats retain their strategy."), + default: false, + scope: ConfigurationScope.APPLICATION, + tags: ['experimental', 'advanced'], + experiment: { mode: 'auto' }, + agentHost: { key: AgentHostDeferredTitleGenerationConfigKey }, + }, +} satisfies Record; diff --git a/src/vs/platform/agentHost/common/webPubSub/chunking.ts b/src/vs/platform/agentHost/common/webPubSub/chunking.ts index f20ae593c778ef..2ef0086c87d370 100644 --- a/src/vs/platform/agentHost/common/webPubSub/chunking.ts +++ b/src/vs/platform/agentHost/common/webPubSub/chunking.ts @@ -22,13 +22,14 @@ import { generateUuid } from '../../../../base/common/uuid.js'; * standard, with padding) of the serialised inner payload. */ export type ChunkEnvelope = - | { readonly kind: 'message'; readonly data: unknown } + | { readonly kind: 'message'; readonly data: unknown; readonly generation?: number } | { readonly kind: 'chunk'; readonly group_id: string; readonly seq: number; readonly total: number; readonly bytes: string; + readonly generation?: number; }; /** Default per-segment ceiling (serialised `ChunkEnvelope` bytes). */ @@ -185,6 +186,7 @@ export interface ReassemblerOptions { } interface ReassemblyBuffer { + generation?: number; total: number; received: number; segments: Map; @@ -262,6 +264,7 @@ export class Reassembler { ); } buffer = { + generation: envelope.generation, total, received: 0, segments: new Map(), @@ -272,6 +275,9 @@ export class Reassembler { } else if (buffer.total !== total) { this.dropBuffer(groupId); throw new ChunkingError(`total mismatch for group_id '${groupId}': existing ${buffer.total}, incoming ${total}`); + } else if (buffer.generation !== envelope.generation) { + this.dropBuffer(groupId); + throw new ChunkingError(`generation mismatch for group_id '${groupId}'`); } if (!Number.isInteger(seq) || seq < 0 || seq >= total) { diff --git a/src/vs/platform/agentHost/common/webPubSub/framing.ts b/src/vs/platform/agentHost/common/webPubSub/framing.ts index 56b3b7c262a983..9b80685f8caa4d 100644 --- a/src/vs/platform/agentHost/common/webPubSub/framing.ts +++ b/src/vs/platform/agentHost/common/webPubSub/framing.ts @@ -12,7 +12,7 @@ // command. The inbound path is the mirror image. This module is pure JSON ↔ // JSON; it owns no WebSocket and speaks no handshake. -import { type ChunkEnvelope, type ChunkOptions, Reassembler, chunk } from './chunking.js'; +import { type ChunkEnvelope, type ChunkOptions, DEFAULT_MAX_CHUNK_BYTES, Reassembler, chunk } from './chunking.js'; import { parseGroupName, type ParseGroupNameOptions, type ParsedGroup } from './groups.js'; /** @@ -61,6 +61,8 @@ export interface BuildPublishOptions { /** Forwarded to {@link chunk}. */ readonly chunkOptions?: ChunkOptions; + /** Opaque host-side connection identity; absent for client-originated frames. */ + readonly generation?: number; } /** @@ -69,7 +71,14 @@ export interface BuildPublishOptions { * each carried by its own frame with its own `ackId`. */ export function buildPublish(options: BuildPublishOptions): SendToGroupCommand[] { - const envelopes = chunk(options.payload, options.chunkOptions); + if (options.generation !== undefined && (!Number.isSafeInteger(options.generation) || options.generation < 1)) { + throw new FramingError('Invalid host connection generation'); + } + const overhead = options.generation === undefined ? 0 : JSON.stringify({ generation: options.generation }).length - 1; + const envelopes = chunk(options.payload, { + ...options.chunkOptions, + maxChunkBytes: (options.chunkOptions?.maxChunkBytes ?? DEFAULT_MAX_CHUNK_BYTES) - overhead, + }); return envelopes.map( envelope => ({ type: 'sendToGroup', @@ -77,7 +86,7 @@ export function buildPublish(options: BuildPublishOptions): SendToGroupCommand[] ackId: options.nextAckId(), dataType: 'json', noEcho: true, - data: envelope, + data: options.generation === undefined ? envelope : { ...envelope, generation: options.generation }, } satisfies SendToGroupCommand), ); } @@ -90,7 +99,10 @@ export function buildPublish(options: BuildPublishOptions): SendToGroupCommand[] * - `kind: 'ignored'` — the frame was not a group-fanout message. */ export type InboundResult = - | { readonly kind: 'payload'; readonly group: ParsedGroup; readonly payload: unknown } + | { readonly kind: 'payload'; readonly group: ParsedGroup; readonly payload: unknown; readonly generation?: number } + | { readonly kind: 'batch'; readonly group: ParsedGroup; readonly payloads: readonly unknown[]; readonly generation?: number } + | { readonly kind: 'closed'; readonly group: ParsedGroup; readonly generation?: number } + | { readonly kind: 'capabilities'; readonly group: ParsedGroup; readonly accepts: readonly string[] } | { readonly kind: 'pending'; readonly group: ParsedGroup } | { readonly kind: 'ignored' }; @@ -141,10 +153,33 @@ export function parseInbound(frame: unknown, options: ParseInboundOptions): Inbo } const group = parseGroupName(frame['group'], options.groupValidation); - const envelope = frame['data'] as ChunkEnvelope; + const fields = frame['data']; + const generation = fields['generation']; + if (generation !== undefined && (typeof generation !== 'number' || !Number.isSafeInteger(generation) || generation < 1)) { + throw new FramingError('Invalid host connection generation'); + } + const identity = generation === undefined ? {} : { generation }; + if (fields['kind'] === 'closed') { + return { kind: 'closed', group, ...identity }; + } + if (fields['kind'] === 'capabilities') { + const accepts = fields['accepts']; + if (!Array.isArray(accepts) || !accepts.every(value => typeof value === 'string') || accepts.length > 256) { + throw new FramingError('Invalid relay framing capabilities'); + } + return { kind: 'capabilities', group, accepts }; + } + if (fields['kind'] === 'batch') { + const items = fields['items']; + if (!Array.isArray(items) || items.length === 0 || items.length > 256 || new TextEncoder().encode(JSON.stringify(fields)).byteLength > DEFAULT_MAX_CHUNK_BYTES) { + throw new FramingError('Invalid relay batch'); + } + return { kind: 'batch', group, payloads: items, ...identity }; + } + const envelope = fields as ChunkEnvelope; const payload = options.reassembler.ingest(envelope); if (payload === null) { return { kind: 'pending', group }; } - return { kind: 'payload', group, payload }; + return { kind: 'payload', group, payload, ...identity }; } diff --git a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts index 7819a7b99df9f8..590a349cbfa0d3 100644 --- a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts @@ -35,7 +35,38 @@ import type { IAgentHostMcpAuthenticationRequest } from '../common/agentHostExte import { AgentHostClientConnectionKind } from '../common/agentHostTelemetry.js'; import type { IAgentHostFirstResponseDiagnostic } from '../common/otel/agentHostTiming.js'; import type { IChatUserInteractionTiming } from '../../otel/common/chatUserInteraction.js'; -import { AgentHostAhpJsonlLoggingSettingId, type AgentHostDebugLogsArtifactKind, AgentHostIpcChannels, AgentHostOTelPolicyIpcChannel, AgentHostRestartIpcChannel, AgentHostWillRestartIpcChannel, AgentSession, IAgentCreateChatRequestOptions, IAgentCreateSessionConfig, IAgentHostInspectInfo, type IAgentHostDebugLogsArtifact, IAgentHostManagementService, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, type IAgentHostOTelSettings, type IAgentHostOTelPolicyReadiness, IAgentHostService, IAgentHostSocketInfo, IAgentPluginUninstallRequest, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IMcpNotification, readAgentHostOTelPolicySettings, type IAgentHostDebugLogsChunk } from '../common/agentService.js'; +import { + AgentHostAhpJsonlLoggingSettingId, + type AgentHostDebugLogsArtifactKind, + AgentHostIpcChannels, + AgentHostOTelPolicyIpcChannel, + AgentHostRestartIpcChannel, + AgentHostWillRestartIpcChannel, + AgentSession, + IAgentCreateChatRequestOptions, + IAgentCreateSessionConfig, + IAgentHostInspectInfo, + type IAgentHostDebugLogsArtifact, + IAgentHostManagementService, + IAgentHostManagedSettingsDiagnostics, + IAgentHostNetworkDiagnosticsInfo, + IAgentHostNetworkFetchResult, + IAgentHostService, + IAgentHostSocketInfo, + type IAgentHostOTelSettings, + type IAgentHostOTelPolicyReadiness, + IAgentPluginUninstallRequest, + type IMissionControlOptions, + type IMissionControlCredentialSealingRequest, + IAgentResolveSessionConfigParams, + IAgentSessionConfigCompletionsParams, + IAgentSessionMetadata, + AuthenticateParams, + AuthenticateResult, + IMcpNotification, + readAgentHostOTelPolicySettings, + type IAgentHostDebugLogsChunk, +} from '../common/agentService.js'; import type { IRemoteWatchHandle } from '../common/agentHostFileSystemProvider.js'; import type { IActiveSubscriptionInfo, IAgentSubscription } from '../common/state/agentSubscription.js'; import type { CompletionsParams, CompletionsResult, ContentEncoding, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; @@ -616,6 +647,19 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos getDelayedChannel(this._managementConnection.client().then(client => client.getChannel(AgentHostIpcChannels.Management))) ); } + + configureMissionControl(options: IMissionControlOptions | undefined, withdrawingAccountId?: string): Promise { + return this._getManagementService().configureMissionControl(options, withdrawingAccountId); + } + + sealMissionControlCredential(request: IMissionControlCredentialSealingRequest): Promise { + return this._getManagementService().sealMissionControlCredential(request); + } + + getMissionControlEnvironmentId(): Promise { + return this._getManagementService().getMissionControlEnvironmentId(); + } + } function hasSessionExtensions(config: IAgentCreateSessionConfig): boolean { diff --git a/src/vs/platform/agentHost/node/agentHostAutomationService.ts b/src/vs/platform/agentHost/node/agentHostAutomationService.ts index 3971e7f07d63fe..42f158ee13fa00 100644 --- a/src/vs/platform/agentHost/node/agentHostAutomationService.ts +++ b/src/vs/platform/agentHost/node/agentHostAutomationService.ts @@ -1007,7 +1007,7 @@ export class AgentHostAutomationService extends Disposable implements IAgentHost runId: AgentSession.id(run.resource), trigger: run.origin.kind === AutomationRunOriginKind.Manual ? 'manual' : run.origin.catchUp ? 'catch_up' : run.origin.scheduledFor ? 'schedule' : 'event', runCreatedAt: run.lifecycle.createdAt, - provider: session ? getAutomationTelemetryProvider(AgentSession.provider(session)) : 'default', + provider: session ? getAutomationTelemetryProvider(this._stateManager.getSessionSummary(session)?.provider ?? AgentSession.provider(session)) : 'default', agentSessionId: session ? AgentSession.id(session) : undefined, sessionCreated: run.sessions.length > 0, }; diff --git a/src/vs/platform/agentHost/node/agentHostBootstrap.ts b/src/vs/platform/agentHost/node/agentHostBootstrap.ts index 0536d650feb34a..6cdcfa9cceb195 100644 --- a/src/vs/platform/agentHost/node/agentHostBootstrap.ts +++ b/src/vs/platform/agentHost/node/agentHostBootstrap.ts @@ -21,6 +21,7 @@ import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { IAgentService } from '../common/agentService.js'; import { ISessionDataService } from '../common/sessionDataService.js'; import type { IAgent } from '../common/agent.js'; +import { AgentHostActiveAgentTitleGenerationConfigKey, AgentHostDeferredTitleGenerationConfigKey, platformRootSchema } from '../common/agentHostSchema.js'; import { createAgentHostTelemetryService } from './agentHostTelemetryService.js'; import { AgentService, IAgentServiceOptions } from './agentService.js'; import { createAgentServiceComposition } from './agentServiceComposition.js'; @@ -187,6 +188,9 @@ export async function createAgentHostRuntime(options: ICreateAgentHostRuntimeOpt getGitHubHost: () => foundation.gitHubEndpointService.getEnterpriseHost() ?? 'github.com', gitHubService, copilotApiService, + getInitialTitleGenerationStrategy: () => foundation.configurationService.getRootValue(platformRootSchema, AgentHostDeferredTitleGenerationConfigKey) === true + ? 'deferred' + : foundation.configurationService.getRootValue(platformRootSchema, AgentHostActiveAgentTitleGenerationConfigKey) === true ? 'activeAgent' : 'utility', }))); const localTurns = new AgentHostLocalTurns(sessionDataService, logService); services.set(IAgentHostLocalTurns, localTurns); diff --git a/src/vs/platform/agentHost/node/agentHostBranchChangesetScope.ts b/src/vs/platform/agentHost/node/agentHostBranchChangesetScope.ts index 3c3dd70833f6be..3db4d4eae67693 100644 --- a/src/vs/platform/agentHost/node/agentHostBranchChangesetScope.ts +++ b/src/vs/platform/agentHost/node/agentHostBranchChangesetScope.ts @@ -112,12 +112,12 @@ export interface IGitHubStateFolder { export function resolveGitHubStateFolder(stateManager: AgentHostStateManager, uri: ProtocolURI): IGitHubStateFolder { const isFolderOwner = !!parseFolderChangesetOwnerUri(uri); const scope = isFolderOwner ? resolveChangesetOwnerScope(stateManager, uri) : resolveBranchChangesetScopeForSource(stateManager, uri); - const workingDirectory = scope.workingDirectories[0]; + const sessionWorkingDirectory = stateManager.getSessionSummary(scope.sessionUri)?.workingDirectories?.[0]; + const workingDirectory = !isFolderOwner && !isAhpChatChannel(uri) ? sessionWorkingDirectory : scope.workingDirectories[0]; if (isFolderOwner && workingDirectory === undefined) { // The folder scope no longer matches any chat; never fall back to the session folder. return { sessionUri: scope.sessionUri, sourceUri: scope.sourceUri, folderKey: undefined, isSessionFolder: false, workingDirectory: undefined }; } - const sessionWorkingDirectory = stateManager.getSessionState(scope.sessionUri)?.workingDirectories?.[0]; const folderKey = workingDirectory === undefined ? undefined : getWorkingDirectoryKey(workingDirectory); return { sessionUri: scope.sessionUri, @@ -161,8 +161,7 @@ export function resolveAgentMergeOwningChat(stateManager: AgentHostStateManager, if (recordedChat && isSessionChatInFolder(stateManager, session, recordedChat, folderKey)) { return recordedChat; } - const sessionWorkingDirectory = state?.workingDirectories?.[0]; - if (sessionWorkingDirectory !== undefined && folderKey === getWorkingDirectoryKey(sessionWorkingDirectory)) { + if (worksInFolder(defaultChat)) { return defaultChat; } for (const chat of [defaultChat, ...state?.chats.map(chat => chat.resource).filter(chat => chat !== defaultChat) ?? []]) { diff --git a/src/vs/platform/agentHost/node/agentHostCatalogListReader.ts b/src/vs/platform/agentHost/node/agentHostCatalogListReader.ts index 417bf5f965e222..df65c92c352446 100644 --- a/src/vs/platform/agentHost/node/agentHostCatalogListReader.ts +++ b/src/vs/platform/agentHost/node/agentHostCatalogListReader.ts @@ -4,7 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { Limiter } from '../../../base/common/async.js'; -import { AgentSession, type IAgentSessionMetadata } from '../common/agent.js'; +import { type IAgentSessionMetadata } from '../common/agent.js'; +import { readCodexSessionModel } from '../common/meta/codexSessionModel.js'; import { ChatOriginKind } from '../common/state/protocol/state.js'; import { isSubagentChatUri, SessionStatus, withMigratedSessionGitHubState, withSessionExternal, withSessionStatusFlag } from '../common/state/sessionState.js'; import { AGENT_HOST_CATALOG_PAYLOAD_VERSION, decodeAgentHostCatalogPayload, reviveAgentHostCatalogData, type AgentHostCatalogRevivedData } from './agentHostCatalogProjection.js'; @@ -87,7 +88,7 @@ export class AgentHostCatalogListReader { if (catalog.isChatBacking) { return { eligible: false, chatBacking: true }; } - if (AgentSession.provider(registered.session) !== registered.provider || catalog.provider !== registered.provider) { + if (catalog.provider !== registered.provider) { return ineligible(`central row provider ${catalog.provider} does not match ${registered.provider}`); } if (catalog.payloadVersion !== AGENT_HOST_CATALOG_PAYLOAD_VERSION) { @@ -116,8 +117,10 @@ export class AgentHostCatalogListReader { status = withSessionStatusFlag(status, SessionStatus.IsArchived, data.isArchived); // Payloads written by earlier versions record the session folder's GitHub state on its own. const meta = withSessionExternal(withMigratedSessionGitHubState(data._meta, data.workingDirectories[0]?.toString()), registered.external); + const model = readCodexSessionModel({ _meta: meta }); return { session: registered.session, + provider: registered.provider, startTime: registered.startTime, // The registry owns durable recency: a live advance can outrun the // payload's own timestamp until the next reconciliation writes it back. @@ -127,6 +130,7 @@ export class AgentHostCatalogListReader { project: data.project, workingDirectories: [...data.workingDirectories], changes: data.changes, + ...(model ? { model } : {}), chats: data.chats.map(chat => ({ chat: chat.uri, summary: chat.summary, diff --git a/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts b/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts index e5c3fc6dbe1239..a425da12dd3326 100644 --- a/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts +++ b/src/vs/platform/agentHost/node/agentHostCatalogProjection.ts @@ -9,6 +9,7 @@ import { stableStringify } from '../../../base/common/objects.js'; import { URI } from '../../../base/common/uri.js'; import { IValidator, ValidationError, ValidatorBase, ValidatorType, vArray, vBoolean, vEnum, vObj, vOptionalProp } from '../../../base/common/validation.js'; import { AH_META_DEV_CONTAINER_WORKTREE_DB_KEY, isAgentDevContainerWorktreeHandle } from '../common/meta/agentDevContainerWorktreeMeta.js'; +import { CODEX_SESSION_MODEL_META_KEY, readCodexSessionModel } from '../common/meta/codexSessionModel.js'; import { readRemoteSessionOrigin, REMOTE_SESSION_ORIGIN_METADATA_KEY } from '../common/meta/agentRemoteSessionMeta.js'; import { SESSION_INITIATOR_METADATA_KEY } from '../common/meta/agentSessionInitiatorMeta.js'; import { SESSION_META_ARTIFACTS_KEY } from '../common/sessionArtifacts.js'; @@ -343,6 +344,12 @@ const devContainerWorktreeValidator = new RefinedValidator(plainObject(vObj({ ? value : { message: 'Expected valid Dev Container worktree metadata.' }); +const codexSessionModelValidator = new RefinedValidator(plainObject(vObj({ + id: boundedString(), +})), value => readCodexSessionModel({ _meta: { [CODEX_SESSION_MODEL_META_KEY]: value } }) + ? value + : { message: 'Expected a canonical Codex provider model selection.' }); + /** * The session's `_meta` bag, validated slot by slot under the same well-known * keys `sessionState.ts` uses, so readers such as `readSessionGitState` accept @@ -369,6 +376,7 @@ const metadataValidator = plainObject(vObj({ [SESSION_META_EHCLI_ADOPTABLE_KEY]: vOptionalProp(vBoolean()), [SESSION_META_EHCLI_ADOPTED_KEY]: vOptionalProp(vBoolean()), [AH_META_DEV_CONTAINER_WORKTREE_DB_KEY]: vOptionalProp(devContainerWorktreeValidator), + [CODEX_SESSION_MODEL_META_KEY]: vOptionalProp(codexSessionModelValidator), })); const workingDirectoriesValidator = new RefinedValidator( diff --git a/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts b/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts index 0e26f8104c62e4..5c4eee983a75ca 100644 --- a/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts +++ b/src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts @@ -6,6 +6,7 @@ import { URI } from '../../../base/common/uri.js'; import { AH_META_DEV_CONTAINER_WORKTREE_DB_KEY, readAgentDevContainerWorktreeMetadata } from '../common/meta/agentDevContainerWorktreeMeta.js'; import { readChatInputState } from '../common/meta/agentHostChatInputState.js'; +import { CODEX_SESSION_MODEL_META_KEY, readCodexSessionModel } from '../common/meta/codexSessionModel.js'; import { parseRemoteSessionOrigin, readRemoteSessionOrigin, REMOTE_SESSION_ORIGIN_METADATA_KEY } from '../common/meta/agentRemoteSessionMeta.js'; import { parseSessionInitiator, readSessionInitiator, SESSION_INITIATOR_METADATA_KEY } from '../common/meta/agentSessionInitiatorMeta.js'; import { parseSessionArtifacts, readSessionArtifacts, SESSION_META_ARTIFACTS_KEY, stringifySessionArtifacts, type ISessionArtifact } from '../common/sessionArtifacts.js'; @@ -15,7 +16,7 @@ import { ChangesSummary, ChatInteractivity, ChatOrigin, ChatOriginKind } from '. import { AH_META_CREATED_BY_SESSION_DB_KEY, AH_META_DEFAULT_CHAT_IS_READ_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, AH_META_WORKSPACELESS_DB_KEY, ISessionGitHubState, ISessionGitState, ISessionSourceControlState, isChatInSessionReadAggregate, parseSessionCreationReference, parseSessionFolderPickerDecision, parseSessionMultiRootMetadata, readSessionCreationReference, readSessionEhcliAdoptable, readSessionEhcliAdopted, readSessionExternal, readSessionFolderPickerDecision, parseSessionGitHubData, parseSessionGitHubState, readSessionGitData, readSessionGitHubData, readSessionGitState, withMigratedSessionGitHubState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, SESSION_META_CREATED_BY_SESSION_KEY, SESSION_META_EHCLI_ADOPTABLE_KEY, SESSION_META_EHCLI_ADOPTED_KEY, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_GIT_DATA_KEY, SESSION_META_GIT_KEY, SESSION_META_GITHUB_DATA_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, SESSION_META_WORKSPACELESS_KEY, SessionStatus, SessionSummary } from '../common/state/sessionState.js'; import { AGENT_HOST_CATALOG_JSON_STRING_LENGTH_LIMIT, AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT, AgentHostCatalogData, AgentHostCatalogJsonValue, AgentHostCatalogMetadata, agentHostCatalogChangesValidator, agentHostCatalogGitDataValidator, agentHostCatalogGitValidator } from './agentHostCatalogProjection.js'; import { IAgentHostCatalogSyncRequest } from './agentHostCatalogSyncService.js'; -import { AGENT_HOST_TITLE_SOURCE_AUTO, AgentHostTitleSource, customChatTitleMetadataKey, customChatTitleSourceMetadataKey, SESSION_ARTIFACTS_KEY, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY } from './shared/persistSessionMetadata.js'; +import { AGENT_HOST_TITLE_SOURCE_AUTO, AgentHostTitleSource, customChatTitleMetadataKey, customChatTitleSourceMetadataKey, parseSessionWorkingDirectories, SESSION_ARTIFACTS_KEY, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY, SESSION_WORKING_DIRECTORIES_KEY } from './shared/persistSessionMetadata.js'; import { WORKTREE_META_REPOSITORY_ROOT } from './shared/worktreeIsolation.js'; export const CHAT_BACKING_METADATA_KEY = 'peerChatBacking'; @@ -107,6 +108,7 @@ const sessionMetadata = { gitData: parsedSessionMetadataKey(META_GIT_DATA_STATE, readPersistedGitData), sourceControl: parsedSessionMetadataKey(META_SOURCE_CONTROL_STATE, readPersistedSourceControlState), devContainerWorktree: parsedSessionMetadataKey(AH_META_DEV_CONTAINER_WORKTREE_DB_KEY, readPersistedDevContainerWorktree), + workingDirectories: parsedSessionMetadataKey(SESSION_WORKING_DIRECTORIES_KEY, parseSessionWorkingDirectories), } as const; const sessionMetadataKeys: readonly ISessionMetadataKey[] = Object.values(sessionMetadata); @@ -227,6 +229,7 @@ export class AgentHostCatalogSourceResolver { const devContainerWorktree = preferPersistedMetadata && sessionMetadata.devContainerWorktree.has(metadata) ? persistedDevContainerWorktree : readAgentDevContainerWorktreeMetadata(state.meta) ?? persistedDevContainerWorktree; + const codexModel = readCodexSessionModel({ _meta: state.meta }); const meta: AgentHostCatalogMetadata = { ...(multiRoot ? { [SESSION_META_MULTI_ROOT_KEY]: multiRoot } : undefined), ...(folderPicker ? { [SESSION_META_FOLDER_PICKER_KEY]: folderPicker } : undefined), @@ -242,6 +245,7 @@ export class AgentHostCatalogSourceResolver { ...(ehcliAdoptable ? { [SESSION_META_EHCLI_ADOPTABLE_KEY]: true } : undefined), ...(ehcliAdopted ? { [SESSION_META_EHCLI_ADOPTED_KEY]: true } : undefined), ...(devContainerWorktree ? { [AH_META_DEV_CONTAINER_WORKTREE_DB_KEY]: devContainerWorktree } : undefined), + ...(codexModel ? { [CODEX_SESSION_MODEL_META_KEY]: { id: codexModel.id } } : undefined), }; const data: AgentHostCatalogData = { modifiedTime: state.modifiedTime, @@ -253,7 +257,7 @@ export class AgentHostCatalogSourceResolver { ? { uri: worktreeProject.uri.toString(), displayName: worktreeProject.displayName } : state.project, isChatBacking: !!sessionMetadata.chatBacking.read(metadata) || this._dependencies.isUnpersistedChatBacking(session), - workingDirectories: state.workingDirectories, + workingDirectories: (preferPersistedMetadata ? sessionMetadata.workingDirectories.read(metadata) : undefined) ?? state.workingDirectories, changes, _meta: Object.keys(meta).length > 0 ? meta : undefined, chats: state.chats.map((chat, order) => { @@ -301,6 +305,9 @@ export class AgentHostCatalogSourceResolver { [SESSION_META_FOLDER_PICKER_KEY]: folderPicker ? JSON.stringify(folderPicker) : '', [SESSION_ARTIFACTS_KEY]: stringifySessionArtifacts(artifacts), }; + if (sessionMetadata.workingDirectories.has(metadata)) { + legacyMetadata[SESSION_WORKING_DIRECTORIES_KEY] = JSON.stringify(data.workingDirectories); + } if (creationReference || metadata[AH_META_CREATED_BY_SESSION_DB_KEY] !== undefined) { legacyMetadata[AH_META_CREATED_BY_SESSION_DB_KEY] = creationReference ? JSON.stringify(creationReference) : ''; } diff --git a/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts b/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts index 01318c6390cbb1..c4ce2f3f9e4f88 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts @@ -179,7 +179,7 @@ export class AgentHostChangesetCoordinator extends Disposable { private onDidChangeSessionConfig(session: string, previous: SessionConfigState | undefined, current: SessionConfigState | undefined): void { this._refreshSummarySource(session, previous); - const sessionFolder = this._stateManager.getSessionState(session)?.workingDirectories?.[0]; + const sessionFolder = this._stateManager.getSessionSummary(session)?.workingDirectories?.[0]; const sessionFolderKey = sessionFolder ? getWorkingDirectoryKey(sessionFolder) : undefined; const wasEnabled = isAnyAgentMergeEnabled(previous?.values, sessionFolderKey); const isEnabled = isAnyAgentMergeEnabled(current?.values, sessionFolderKey); diff --git a/src/vs/platform/agentHost/node/agentHostChangesetService.ts b/src/vs/platform/agentHost/node/agentHostChangesetService.ts index db941198d41a1a..e8276a636ce20f 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetService.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetService.ts @@ -67,7 +67,7 @@ import { AgentSession } from '../common/agent.js'; import { IAgentHostWorktreeIsolation, type IAgentHostWorktreePendingState } from './shared/worktreeIsolation.js'; import { getSummaryChangesetKind, resolveChangesetSubscriptions } from './agentHostChangesetSummary.js'; import { SessionConfigKey } from '../common/sessionConfigKeys.js'; -import { resolveAgentMergeOwningChat, resolveBranchChangesetScopeForOwner, resolveBranchChangesetScopeForSource } from './agentHostBranchChangesetScope.js'; +import { resolveAgentMergeOwningChat, resolveBranchChangesetScopeForOwner, resolveBranchChangesetScopeForSource, resolveGitHubStateFolder } from './agentHostBranchChangesetScope.js'; /** * Maximum number of per-repository git diffs a multi-folder fan-out runs at @@ -2143,10 +2143,13 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC * and the review-status lookup so both are keyed on the same baseline. */ private async _resolveBranchBaseBranch(session: ProtocolURI, db: ISessionDatabase): Promise { - const persistedBaseBranch = await db.getMetadata(META_DIFF_BASE_BRANCH); const source = resolveBranchChangesetScopeForOwner(this._stateManager, session)?.sourceUri ?? session; + const isSessionFolder = resolveGitHubStateFolder(this._stateManager, source).isSessionFolder; + const persistedBaseBranch = !isDefaultChatUri(source) || isSessionFolder + ? await db.getMetadata(META_DIFF_BASE_BRANCH) + : undefined; const gitStateBaseBranch = this._gitStateService.getSessionGitState?.(source)?.baseBranchName - ?? (!isAhpChatChannel(source) || isDefaultChatUri(source) + ?? (isSessionFolder && (!isAhpChatChannel(source) || isDefaultChatUri(source)) ? readSessionGitState(this._stateManager.getSessionState(containingSessionUri(source))?._meta)?.baseBranchName : undefined); if (!persistedBaseBranch && gitStateBaseBranch) { diff --git a/src/vs/platform/agentHost/node/agentHostClientConnectionService.ts b/src/vs/platform/agentHost/node/agentHostClientConnectionService.ts index 7ffd8b48283efe..14398690f86ddb 100644 --- a/src/vs/platform/agentHost/node/agentHostClientConnectionService.ts +++ b/src/vs/platform/agentHost/node/agentHostClientConnectionService.ts @@ -20,6 +20,8 @@ export interface IAgentHostClientConnectionSource { isClientConnected(clientId: string): boolean; /** Whether an active connection is local and uses the server's MessagePort transport. */ isLocalClient(clientId: string): boolean; + /** Compatibility addressing for a positively identified legacy VS Code connection. */ + usesLegacySessionUris?(clientId: string): boolean; getConnectedClientTransportCounts(): ReadonlyMap; requestWorkspaceTrust(clientId: string, request: IAgentHostWorkspaceTrustRequest): Promise; /** Requests silent MCP authentication from connected clients. */ @@ -35,6 +37,7 @@ export interface IAgentHostClientConnectionService { isClientConnected(clientId: string): boolean; /** Whether any source has an active local MessagePort connection for this client. */ isLocalClient(clientId: string): boolean; + usesLegacySessionUris(clientId: string): boolean; getConnectionCounts(clientId: string): IAgentHostClientConnectionCounts; requestWorkspaceTrust(clientId: string, request: IAgentHostWorkspaceTrustRequest): Promise; /** Requests silent MCP authentication, stopping at the first successful source. */ @@ -105,6 +108,10 @@ export class AgentHostClientConnectionService extends Disposable implements IAge }; } + usesLegacySessionUris(clientId: string): boolean { + return [...this._sources].some(source => source.usesLegacySessionUris?.(clientId) === true); + } + async requestMcpAuthentication(request: IAgentHostMcpAuthenticationRequest): Promise { for (const source of this._sources) { if (await source.requestMcpAuthentication(request)) { diff --git a/src/vs/platform/agentHost/node/agentHostContributions.ts b/src/vs/platform/agentHost/node/agentHostContributions.ts index ac65d83300d900..fe9b14133aca3a 100644 --- a/src/vs/platform/agentHost/node/agentHostContributions.ts +++ b/src/vs/platform/agentHost/node/agentHostContributions.ts @@ -45,6 +45,7 @@ export function activateAgentHostContributions(accessor: ServicesAccessor, insta ))); store.add(completions.registerProvider(new CodexCompactCompletionProvider( session => (stateManager.getSessionState(session)?.turns.length ?? 0) > 0, + session => stateManager.getSessionSummary(session)?.provider, ))); store.add(registerBuiltInChatContributions(accessor.get(IAgentHostChatContributions))); return store; diff --git a/src/vs/platform/agentHost/node/agentHostGitStateService.ts b/src/vs/platform/agentHost/node/agentHostGitStateService.ts index 73ce78a1e587f5..9c5c0738abb26c 100644 --- a/src/vs/platform/agentHost/node/agentHostGitStateService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitStateService.ts @@ -139,7 +139,7 @@ export class AgentHostGitStateService extends Disposable implements IAgentHostGi } private async _attachSessionGitHubPullRequest(sessionKey: string): Promise { - const state = this._stateManager.getSessionState(sessionKey); + const state = this._getStateForGitScope(sessionKey); if (!state) { return; } @@ -175,7 +175,7 @@ export class AgentHostGitStateService extends Disposable implements IAgentHostGi gitHubState, gitState, hasGitHubToken: () => !!this._getGitHubAuthToken(), - getCurrentSessionState: () => this._stateManager.getSessionState(sessionKey), + getCurrentSessionState: () => this._getStateForGitScope(sessionKey), isRestrictedMode: () => !this._isAutomaticPullRequestAttachmentEnabled(), }); if (result.kind === 'retry') { @@ -219,7 +219,7 @@ export class AgentHostGitStateService extends Disposable implements IAgentHostGi return; } - const currentState = this._stateManager.getSessionState(sessionKey); + const currentState = this._getStateForGitScope(sessionKey); if (!currentState) { return; } @@ -355,15 +355,22 @@ export class AgentHostGitStateService extends Disposable implements IAgentHostGi || gitHubState?.initialPullRequestUrls !== undefined; } + private _getStateForGitScope(key: string): ISessionWithDefaultChat | undefined { + const state = this._stateManager.getSessionState(key); + return state && !isAhpChatChannel(key) + ? { ...state, workingDirectories: this._stateManager.getSessionSummary(key)?.workingDirectories } + : state; + } + async refreshSessionGitState(sessionKey: string, workingDirectory: URI | undefined): Promise { - const sessionState = this._stateManager.getSessionState(sessionKey); + const sessionState = this._getStateForGitScope(sessionKey); if (sessionState?.lifecycle === SessionLifecycle.Failed) { return; } const initialPrimaryWorkingDirectory = sessionState?.workingDirectories?.[0]; if (!workingDirectory) { - const workingDirectoryStr = sessionState?.workingDirectories?.[0]; + const workingDirectoryStr = initialPrimaryWorkingDirectory; if (workingDirectoryStr) { workingDirectory = URI.parse(workingDirectoryStr); } @@ -383,8 +390,7 @@ export class AgentHostGitStateService extends Disposable implements IAgentHostGi const baseBranchName = await this.resolveSessionBaseBranchName(sessionKey); const gitState = await this._gitService.getSessionGitState(workingDirectory, baseBranchName); - const currentState = this._stateManager.getSessionState(sessionKey); - const currentWorkingDirectory = currentState?.workingDirectories?.[0]; + const currentWorkingDirectory = this._getStateForGitScope(sessionKey)?.workingDirectories?.[0]; const primaryWorkingDirectoryChanged = initialPrimaryWorkingDirectory === undefined ? currentWorkingDirectory !== undefined : currentWorkingDirectory === undefined || !isEqual(URI.parse(initialPrimaryWorkingDirectory), URI.parse(currentWorkingDirectory)); @@ -469,7 +475,7 @@ export class AgentHostGitStateService extends Disposable implements IAgentHostGi } const scope = resolveBranchChangesetScopeForSource(this._stateManager, sessionKey); const sessionState = this._stateManager.getSessionState(scope.sessionUri); - if (isDefaultChatUri(sessionKey)) { + if (isDefaultChatUri(sessionKey) && scope.workingDirectories[0] === this._stateManager.getSessionSummary(scope.sessionUri)?.workingDirectories?.[0]) { return readSessionGitState(sessionState?._meta); } return readFolderScopeGitState(sessionState?._meta, getWorkingDirectoryScopeId(scope.workingDirectories)) @@ -830,9 +836,14 @@ export class AgentHostGitStateService extends Disposable implements IAgentHostGi } async resolveSessionBaseBranchName(sessionKey: string): Promise { - const state = this._stateManager.getSessionState(sessionKey); + const state = this._getStateForGitScope(sessionKey); + const gitStateBaseBranch = this.getSessionGitState(sessionKey)?.baseBranchName; + const isSessionFolder = resolveGitHubStateFolder(this._stateManager, sessionKey).isSessionFolder; + if (!isSessionFolder && isDefaultChatUri(sessionKey)) { + return gitStateBaseBranch; + } const configValues = state?.config?.values; - const configuredBranch = configValues?.[SessionConfigKey.Isolation] === 'worktree' + const configuredBranch = isSessionFolder && configValues?.[SessionConfigKey.Isolation] === 'worktree' && configValues[SessionConfigKey.WorktreeCreateNewBranch] !== false ? configValues[SessionConfigKey.Branch] : undefined; @@ -840,7 +851,6 @@ export class AgentHostGitStateService extends Disposable implements IAgentHostGi return resolveDiffBaseBranchName(configuredBranch.trim(), undefined); } - const gitStateBaseBranch = this.getSessionGitState(sessionKey)?.baseBranchName; const workingDirectory = state?.workingDirectories?.[0]; const project = state?.project?.uri; if (!workingDirectory || !project || isEqual(URI.parse(workingDirectory), URI.parse(project))) { @@ -899,16 +909,20 @@ export class AgentHostGitStateService extends Disposable implements IAgentHostGi private async _setChatGitState(sessionKey: string, gitState: ISessionGitState | undefined): Promise { const scope = resolveBranchChangesetScopeForSource(this._stateManager, sessionKey); - if (isDefaultChatUri(sessionKey)) { + if (isDefaultChatUri(sessionKey) && scope.workingDirectories[0] === this._stateManager.getSessionSummary(scope.sessionUri)?.workingDirectories?.[0]) { await this._setSessionGitState(scope.sessionUri, gitState); return; } - const scopeId = getWorkingDirectoryScopeId(scope.workingDirectories); - const currentMeta = this._stateManager.getSessionState(scope.sessionUri)?._meta; - this._stateManager.setSessionMeta(scope.sessionUri, withFolderScopeGitState(currentMeta, scopeId, gitState, scope.workingDirectories)); - await this._gitStateSaves.queue(scope.sessionUri, async () => { - const gitData = readSessionGitData(this._stateManager.getSessionState(scope.sessionUri)?._meta); - await this._saveSessionState(scope.sessionUri, META_GIT_DATA_STATE, JSON.stringify(Object.fromEntries(gitData))); + await this.setFolderGitState(scope.sessionUri, scope.workingDirectories, gitState); + } + + async setFolderGitState(sessionKey: string, workingDirectories: readonly string[], gitState: ISessionGitState | undefined): Promise { + const scopeId = getWorkingDirectoryScopeId(workingDirectories); + const currentMeta = this._stateManager.getSessionState(sessionKey)?._meta; + this._stateManager.setSessionMeta(sessionKey, withFolderScopeGitState(currentMeta, scopeId, gitState, workingDirectories)); + await this._gitStateSaves.queue(sessionKey, async () => { + const gitData = readSessionGitData(this._stateManager.getSessionState(sessionKey)?._meta); + await this._saveSessionState(sessionKey, META_GIT_DATA_STATE, JSON.stringify(Object.fromEntries(gitData))); }); } diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index c64120614ab191..8b99aa6c724fd3 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -32,6 +32,7 @@ import { IAgentHostProxyResolver } from './agentHostProxyResolver.js'; import { IAgentSdkDownloader, type IAgentSdkDownloadProgress } from './agentSdkDownloader.js'; import { IAgentHostProviderService } from './agentHostProviderService.js'; import { ProtocolServerHandler } from './protocolServerHandler.js'; +import { MissionControlHost } from './missionControl/missionControlHost.js'; import { WebSocketProtocolServer } from './webSocketTransport.js'; import { MessagePortProtocolServer } from './messagePortProtocolServer.js'; import { cleanupLocalAgentHostEndpointMetadataSync, cleanupLocalAgentHostEndpointSocketSync, createLocalAgentHostEndpointMetadata, prepareLocalAgentHostEndpointMetadataDirectory, prepareLocalAgentHostEndpointSocketDirectory, publishLocalAgentHostEndpointMetadata, type ILocalAgentHostEndpointMetadata } from './localAgentHostMetadata.js'; @@ -447,15 +448,35 @@ async function startAgentHost(): Promise { } }, }; - server.registerChannel(AgentHostIpcChannels.Management, ProxyChannel.fromService(instantiationService.createInstance( + const missionControl = server instanceof UtilityProcessServer + ? protocolIngressDisposables.add(instantiationService.createInstance(MissionControlHost, { + hostLaunchKind, + clientFileSystemProvider, + trackProtocolHandler: handler => { + protocolHandlers.push(handler); + return toDisposable(() => { + protocolHandlers.splice(protocolHandlers.indexOf(handler), 1); + handler.dispose(); + }); + }, + })) + : undefined; + const management = instantiationService.createInstance( AgentHostManagementService, agentService, connectionTrackerService, async () => { + try { + await missionControl?.environment.configure(undefined); + } catch (error) { + logService.error('[AgentHost] Failed to unregister Mission Control environment', error); + } protocolIngressDisposables.dispose(); await Promise.all(protocolHandlers.map(handler => handler.whenIdle())); }, - ), disposables)); + ); + management.setMissionControl(missionControl?.environment); + server.registerChannel(AgentHostIpcChannels.Management, ProxyChannel.fromService(management, disposables)); if (!(server instanceof UtilityProcessServer)) { server.registerChannel(AgentHostIpcChannels.ConnectionTracker, ProxyChannel.fromService(connectionTrackerService, disposables)); } diff --git a/src/vs/platform/agentHost/node/agentHostManagementService.ts b/src/vs/platform/agentHost/node/agentHostManagementService.ts index 0927f086c16018..f02b020437eb0d 100644 --- a/src/vs/platform/agentHost/node/agentHostManagementService.ts +++ b/src/vs/platform/agentHost/node/agentHostManagementService.ts @@ -7,7 +7,9 @@ import { Promises, raceTimeout } from '../../../base/common/async.js'; import { URI } from '../../../base/common/uri.js'; import { ILogService } from '../../log/common/log.js'; import { type AgentProvider, IAgentCreateChatRequestOptions, IAgentCreateSessionConfig, type IAgentPluginUninstallRequest } from '../common/agent.js'; -import { IAgentHostInspectInfo, IAgentHostManagedSettingsDiagnostics, IAgentHostManagementService, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, IAgentHostSocketInfo, IAgentService, IConnectionTrackerService, type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk } from '../common/agentService.js'; +import { IAgentHostInspectInfo, IAgentHostManagedSettingsDiagnostics, IAgentHostManagementService, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, IAgentHostSocketInfo, IAgentService, IConnectionTrackerService, type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk, type IMissionControlOptions, type IMissionControlCredentialSealingRequest } from '../common/agentService.js'; +import { sealMissionControlCredential } from './missionControl/missionControlAuthentication.js'; +import { MissionControlEnvironment } from './missionControl/missionControlEnvironment.js'; import { ISessionDataService } from '../common/sessionDataService.js'; const SHUTDOWN_DRAIN_TIMEOUT_MS = 1000; @@ -16,6 +18,7 @@ const SHUTDOWN_FLUSH_TIMEOUT_MS = 2500; export class AgentHostManagementService implements IAgentHostManagementService { declare readonly _serviceBrand: undefined; + private _missionControl: MissionControlEnvironment | undefined; private _shutdownPromise: Promise | undefined; private _shuttingDown = false; @@ -29,6 +32,28 @@ export class AgentHostManagementService implements IAgentHostManagementService { @ILogService private readonly _logService: ILogService, ) { } + setMissionControl(service: MissionControlEnvironment | undefined): void { + this._missionControl = service; + } + + configureMissionControl(options: IMissionControlOptions | undefined, withdrawingAccountId?: string): Promise { + if (!this._missionControl) { + throw new Error('Mission Control is unavailable in this Agent Host'); + } + return this._missionControl.configure(options, withdrawingAccountId); + } + + sealMissionControlCredential(request: IMissionControlCredentialSealingRequest): Promise { + if (!this._missionControl || this._shuttingDown) { + throw new Error('Local Mission Control sealing is unavailable'); + } + return sealMissionControlCredential(request); + } + + async getMissionControlEnvironmentId(): Promise { + return this._missionControl?.environmentId; + } + createSessionWithExtensions(config: IAgentCreateSessionConfig): Promise { return this._runMutation(() => this._agentService.createSession(config)); } diff --git a/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts b/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts index 88c5fe60be3cb9..3ef2bcde4d2d71 100644 --- a/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts +++ b/src/vs/platform/agentHost/node/agentHostPeerChatStore.ts @@ -11,6 +11,7 @@ import { isUUID } from '../../../base/common/uuid.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import { ILogService } from '../../log/common/log.js'; import { ISessionDataService } from '../common/sessionDataService.js'; +import { AgentSession, COPILOT_CLI_AGENT_PROVIDER_ID } from '../common/agent.js'; import type { AgentHostCatalogDatabaseReference } from './agentHostCatalogSyncService.js'; import { ChatOrigin } from '../common/state/protocol/state.js'; import { AH_META_IS_READ_DB_KEY, buildChatUri, isDefaultChatUri, parseRequiredSessionUriFromChatUri } from '../common/state/sessionState.js'; @@ -85,7 +86,7 @@ export class AgentHostPeerChatStore implements IAgentHostPeerChatPersistenceServ /** Recovers legacy peer membership only when a restored chat-selection phantom is independently verified. */ async recoverChatSelectionCorruption(session: URI, phantomChatIds: readonly string[]): Promise<{ readonly entries: readonly IPersistedPeerChat[]; readonly verifiedPhantomChatIds: readonly string[] } | undefined> { - if (session.scheme !== 'copilotcli' || session.authority || session.query || session.fragment || !isUUID(session.path.slice(1)) || !this._sessionDataService.listSessionDataIds) { + if (AgentSession.provider(session) !== COPILOT_CLI_AGENT_PROVIDER_ID || session.authority || session.query || session.fragment || !isUUID(session.path.slice(1)) || !this._sessionDataService.listSessionDataIds) { return undefined; } const dataIds = await this._sessionDataService.listSessionDataIds(''); diff --git a/src/vs/platform/agentHost/node/agentHostProviderService.ts b/src/vs/platform/agentHost/node/agentHostProviderService.ts index ed43a3dbc99ba4..2184d04b140fa8 100644 --- a/src/vs/platform/agentHost/node/agentHostProviderService.ts +++ b/src/vs/platform/agentHost/node/agentHostProviderService.ts @@ -136,7 +136,7 @@ export class AgentHostProviderService extends Disposable implements IAgentHostPr if (schemeProvider) { return this._providers.get(schemeProvider); } - return this._defaultProvider ? this._providers.get(this._defaultProvider) : undefined; + return undefined; } getProviders(): readonly IAgent[] { diff --git a/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts b/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts index 2ce0a1bd62e622..db6d9b6d1c2ed7 100644 --- a/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts +++ b/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts @@ -916,7 +916,7 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen // Tool membership is only a compatibility fallback for sessions materialized before strategy snapshots. const strategy = state?.serverTools ? state.serverTools.some(tool => tool.name === SessionServerToolName.RenameChat) ? 'activeAgent' : 'utility' - : this._options.getInitialTitleGenerationStrategy?.() ?? 'deferred'; + : this._options.getInitialTitleGenerationStrategy?.() ?? 'utility'; if (channel && !this._isEphemeralSession(channel)) { this._titleGenerationStrategies.set(channel, strategy); if (state) { @@ -928,7 +928,7 @@ export class AgentHostSessionTitleController extends Disposable implements IAgen return strategy; } - /** Restores scheduling without changing the strategy of legacy sessions. */ + /** Restores scheduling without opting legacy sessions into the deferred experiment. */ async restoreTitleGenerationStrategy(channel: ProtocolURI, chatChannel?: ProtocolURI): Promise { await this._restoreTitleGenerationStrategy(channel); if (chatChannel && this._titleGenerationStrategies.get(channel) === 'deferred') { diff --git a/src/vs/platform/agentHost/node/agentHostSessionTitleSignal.ts b/src/vs/platform/agentHost/node/agentHostSessionTitleSignal.ts index 137a4655a63def..ae3fdf72e8e7cc 100644 --- a/src/vs/platform/agentHost/node/agentHostSessionTitleSignal.ts +++ b/src/vs/platform/agentHost/node/agentHostSessionTitleSignal.ts @@ -13,7 +13,7 @@ import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateM export const IAgentHostSessionTitleSignal = createDecorator('agentHostSessionTitleSignal'); export interface IAgentHostSessionTitleChangeEvent { - /** Provider owning `session`, derived from the session URI scheme. */ + /** Registered provider owning `session`. */ readonly provider: AgentProvider; readonly session: URI; /** Provider-facing conversation id ({@link AgentSession.id}), precomputed so consumers don't re-derive it. */ @@ -45,7 +45,7 @@ export class AgentHostSessionTitleSignal extends Disposable implements IAgentHos ) { super(); this._register(stateManager.onDidChangeSessionTitle(({ session, title }) => { - const provider = AgentSession.provider(session); + const provider = stateManager.getSessionSummary(session)?.provider ?? AgentSession.provider(session); if (!provider) { return; } diff --git a/src/vs/platform/agentHost/node/agentHostShutdown.ts b/src/vs/platform/agentHost/node/agentHostShutdown.ts index 61077de3c42cbd..4a4ca202806211 100644 --- a/src/vs/platform/agentHost/node/agentHostShutdown.ts +++ b/src/vs/platform/agentHost/node/agentHostShutdown.ts @@ -42,11 +42,13 @@ export async function flushAgentHostPersistenceBeforeShutdown( timeoutMs: number, logService: Pick, ): Promise { - try { - await raceTimeout(Promise.all(flushes), timeoutMs, () => { - logService.warn('[AgentHostServer] Timed out waiting for persistence writes to flush; exiting anyway.'); - }); - } catch (error) { - logService.error('[AgentHostServer] Failed to flush persistence writes during shutdown; exiting anyway.', error); - } + await raceTimeout(Promise.all(flushes.map(async flush => { + try { + await flush; + } catch (error) { + logService.error('[AgentHostServer] Failed to flush persistence writes during shutdown; exiting anyway.', error); + } + })), timeoutMs, () => { + logService.warn('[AgentHostServer] Timed out waiting for persistence writes to flush; exiting anyway.'); + }); } diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index ab6d32f84f6425..02c79bc50d32b0 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -575,6 +575,12 @@ export class AgentHostStateManager extends Disposable { return this._chatEntries.get(chat)?.state; } + /** Captures registration identity so asynchronous mutations cannot target a recreated chat. */ + captureChatValidity(chat: URI): () => boolean { + const entry = this._chatEntries.get(chat); + return () => !!entry?.valid && this._chatEntries.get(chat) === entry; + } + /** * Returns a chat's {@link ChatOrigin} from its catalog summary, not its * (lazily-materialized) {@link ChatState}: a restored chat registers its @@ -943,8 +949,8 @@ export class AgentHostStateManager extends Disposable { * `workingDirectory`, `modifiedAt`, `changes`) from the supplied summary * onto the session entry so subscribers see them. The reducer-owned metadata * (`title`, `status`, `activity`) is intentionally NOT copied back — the live - * state is authoritative for those. Project remains catalog-only while the - * resolved working directories are synchronized session state. No-ops for + * state is authoritative for those. Project and resolved working directories + * also update future session snapshots. No-ops for * sessions that were already announced (idempotent). */ markSessionPersisted(session: URI, summary: SessionSummary, force = false): void { @@ -963,7 +969,7 @@ export class AgentHostStateManager extends Disposable { // `SessionSummaryChanged` flush because the upcoming `SessionAdded` // notification carries the complete summary already. const workingDirectoriesChanged = !equals(entry.state.workingDirectories, summary.workingDirectories); - entry.state = { ...entry.state, workingDirectories: summary.workingDirectories, _meta: summary._meta }; + entry.state = { ...entry.state, project: summary.project, workingDirectories: summary.workingDirectories, _meta: summary._meta }; if (workingDirectoriesChanged) { this._onDidChangeSessionWorkingDirectories.fire({ session: key }); } @@ -1592,7 +1598,7 @@ export class AgentHostStateManager extends Disposable { this.dispatchServerAction(session, { type: ActionType.SessionMetaChanged, _meta: meta }); } - /** Updates catalog-only host-resolved project metadata. */ + /** Updates host-resolved project metadata in the catalogue and future session snapshots. */ setSessionProject(session: URI, project: SessionSummary['project']): void { const entry = this._sessionStates.get(session); if (!entry) { @@ -1603,6 +1609,7 @@ export class AgentHostStateManager extends Disposable { return; } entry.project = project; + entry.state = { ...entry.state, project }; this._summaryNotifier.markDirty(session); } diff --git a/src/vs/platform/agentHost/node/agentHostStorageService.ts b/src/vs/platform/agentHost/node/agentHostStorageService.ts index 370ee7e775a47d..0a1acc74d2992c 100644 --- a/src/vs/platform/agentHost/node/agentHostStorageService.ts +++ b/src/vs/platform/agentHost/node/agentHostStorageService.ts @@ -9,6 +9,7 @@ import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { dirname } from '../../../base/common/path.js'; import { URI } from '../../../base/common/uri.js'; +import { generateUuid } from '../../../base/common/uuid.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import { ILogService } from '../../log/common/log.js'; @@ -28,16 +29,20 @@ export interface IAgentHostStorageService { export interface IAgentHostStorageWriter { mkdir(path: string): Promise; writeFile(path: string, contents: string): Promise; + rename(from: string, to: string): Promise; + rm(path: string): Promise; } const defaultStorageWriter: IAgentHostStorageWriter = { mkdir: path => fs.promises.mkdir(path, { recursive: true }).then(() => undefined), writeFile: (path, contents) => fs.promises.writeFile(path, contents, 'utf8'), + rename: (from, to) => fs.promises.rename(from, to), + rm: path => fs.promises.rm(path, { force: true }), }; /** * A small host-owned persistent store. Reads are synchronously available after - * construction; writes are coalesced so callers never wait on disk I/O. + * construction; writes are coalesced and atomically replace the persisted file. */ export class AgentHostStorageService extends Disposable implements IAgentHostStorageService { declare readonly _serviceBrand: undefined; @@ -144,7 +149,13 @@ export class AgentHostStorageService extends Disposable implements IAgentHostSto const write = this._writeThrottler.queue(async () => { await this._writer.mkdir(dirname(resource.fsPath)); - await this._writer.writeFile(resource.fsPath, JSON.stringify(this._data)); + const temporaryPath = `${resource.fsPath}.${generateUuid()}.tmp`; + try { + await this._writer.writeFile(temporaryPath, JSON.stringify(this._data)); + await this._writer.rename(temporaryPath, resource.fsPath); + } finally { + await this._writer.rm(temporaryPath); + } }); this._pendingWrites.add(write); const untrack = () => this._pendingWrites.delete(write); diff --git a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts index de29a17066f1d7..4ea96788948aef 100644 --- a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts +++ b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts @@ -23,7 +23,7 @@ import type { ToolInvokedResult } from './agentHostToolCallTracker.js'; import type { AutomaticTitleGenerationStrategy } from './agentHostSessionTitleController.js'; import { multiplexProperties, type IAgentHostRestrictedTelemetry, type IAgentHostRestrictedTelemetryContext } from './agentHostRestrictedTelemetry.js'; import { AgentHostClientType } from '../common/agentHostClientInfo.js'; -import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind, type AgentHostProviderSendStage, type AgentHostTurnFailureStage, type AgentHostTurnSendStage, type IAgentHostClientTelemetryContext, type IAgentProviderTurnTelemetryContext, type ICodexAccountTelemetryContext } from '../common/agentHostTelemetry.js'; +import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind, type CodexModelProvider, type AgentHostProviderSendStage, type AgentHostTurnFailureStage, type AgentHostTurnSendStage, type IAgentHostClientTelemetryContext, type IAgentProviderTurnTelemetryContext, type ICodexAccountTelemetryContext } from '../common/agentHostTelemetry.js'; import { isAgentHostTelemetryService } from './agentHostTelemetryService.js'; import { getCodexAccountTelemetryData, type CodexAccountTelemetryClassification } from './codex/codexAccountTelemetry.js'; @@ -240,7 +240,7 @@ export interface IAgentHostClientConnectionReport { export type AgentHostTurnResult = 'success' | 'error' | 'cancelled'; export type AgentHostModelTelemetryKind = 'trusted' | 'byok' | 'unknown'; -type AgentHostModelSelectionKind = 'default' | 'auto' | 'explicit'; +export type AgentHostModelSelectionKind = 'default' | 'auto' | 'hydrafusion' | 'explicit'; export type { AgentHostTurnFailureStage, AgentHostTurnSendStage }; export type AgentHostInitiatorClientConnectionState = 'connected' | 'disconnected' | 'unknown'; export type AgentHostProviderDiagnosticState = 'available' | 'error' | 'missingChat' | 'missingTurn' | 'unavailable' | 'unsupported'; @@ -251,6 +251,7 @@ interface IAgentHostTurnAttributedReport { } export interface IAgentHostTurnCompletedEvent extends IAgentHostEventTelemetry, Partial { + codexModelProvider?: CodexModelProvider; hostRootTurnOrdinal?: number; hostProcessAgeMs?: number; titleGenerationStrategy?: AutomaticTitleGenerationStrategy; @@ -307,6 +308,7 @@ export interface IAgentHostTurnCompletedEvent extends IAgentHostEventTelemetry, } export type IAgentHostTurnCompletedClassification = IAgentHostEventClassification & CodexAccountTelemetryClassification & IAgentHostSubagentKindClassification & { + codexModelProvider?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded model provider actually used by a Codex turn: openai, copilot, other, or unknown.' }; hostRootTurnOrdinal?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'One-based root-turn ordinal over the agent host process lifetime, captured at turn start and excluding subagent turns.' }; hostProcessAgeMs?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Agent host process age in milliseconds captured at root turn start, not completion.' }; titleGenerationStrategy?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The effective persisted automatic title generation strategy captured before sending the root turn: activeAgent, utility, or deferred.' }; @@ -344,7 +346,7 @@ export type IAgentHostTurnCompletedClassification = IAgentHostEventClassificatio totalTime: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total time in milliseconds from turn start to turn completion.' }; result: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the turn completed successfully, with an error, or was cancelled.' }; model: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The trusted provider model identifier selected at turn start, or a generic value for BYOK and unknown models.' }; - modelSelectionKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the client used the provider default, Auto, or an explicit model.' }; + modelSelectionKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the turn used the provider default, Auto, HydraFusion, or an explicit model.' }; isBYOK: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the selected model is a bring-your-own-key model, when model context is available.' }; permissionLevel: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The tool auto-approval level configured for the session at turn start (e.g. default, autoApprove, autopilot).' }; interactionMode: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The agent host interaction mode configured at turn start.' }; @@ -410,6 +412,7 @@ export interface IAgentHostTurnFailure { export interface IAgentHostTurnCompletedReport extends IAgentHostTurnAttributedReport { providerTelemetryContext?: IAgentProviderTurnTelemetryContext; + codexModelProvider?: CodexModelProvider; hostRootTurnOrdinal?: number; hostProcessAgeMs?: number; titleGenerationStrategy?: AutomaticTitleGenerationStrategy; @@ -619,7 +622,7 @@ export type IAgentHostTurnHungClassification = IAgentHostEventClassification & C quietTimeMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Time in milliseconds since the last observed turn activity.' }; turnElapsedMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Time in milliseconds from turn start to the hang report.' }; model: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The trusted provider model identifier for the turn, or a generic value for BYOK and unknown models.' }; - modelSelectionKind: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the client used the provider default, Auto, or an explicit model.' }; + modelSelectionKind: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the turn used the provider default, Auto, HydraFusion, or an explicit model.' }; permissionLevel: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The tool auto-approval level configured for the session at turn start (e.g. default, autoApprove, autopilot).' }; owner: 'roblourens'; comment: 'Tracks agent host turns that stop making progress for longer than the hang threshold, so permanently stuck sessions are visible as a positive signal instead of missing turnCompleted events.'; @@ -1566,6 +1569,7 @@ export class AgentHostTelemetryReporter { ...toInitiatorTelemetry(report.clientContext), ...report.telemetryContext, ...(report.provider === 'codex' ? getCodexAccountTelemetryData(report.providerTelemetryContext?.codex) : undefined), + ...(report.provider === 'codex' ? { codexModelProvider: report.codexModelProvider ?? 'unknown' } : {}), ...(report.hostRootTurnOrdinal !== undefined ? { hostRootTurnOrdinal: report.hostRootTurnOrdinal } : {}), ...(report.hostProcessAgeMs !== undefined ? { hostProcessAgeMs: report.hostProcessAgeMs } : {}), ...(report.titleGenerationStrategy !== undefined ? { titleGenerationStrategy: report.titleGenerationStrategy } : {}), diff --git a/src/vs/platform/agentHost/node/agentHostTurnTelemetryContext.ts b/src/vs/platform/agentHost/node/agentHostTurnTelemetryContext.ts index ed728727ab9fa7..fbc9a8ae68e7da 100644 --- a/src/vs/platform/agentHost/node/agentHostTurnTelemetryContext.ts +++ b/src/vs/platform/agentHost/node/agentHostTurnTelemetryContext.ts @@ -3,20 +3,21 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { IAgent, IAgentChatContext } from '../common/agent.js'; +import { COPILOT_CLI_AGENT_PROVIDER_ID, type IAgent, type IAgentChatContext } from '../common/agent.js'; import type { SessionMode } from '../common/agentHostSchema.js'; import type { IAgentProviderTurnTelemetryContext } from '../common/agentHostTelemetry.js'; import { readAgentModelByokIdentifier } from '../common/agentModelByokMeta.js'; +import { COPILOT_HYDRA_FUSION_MODEL_ID } from '../common/copilotCliConfig.js'; import { SessionConfigKey } from '../common/sessionConfigKeys.js'; import type { SessionState, URI as ProtocolURI } from '../common/state/sessionState.js'; import { URI } from '../../../base/common/uri.js'; -import type { AgentHostModelTelemetryKind } from './agentHostTelemetryReporter.js'; +import type { AgentHostModelSelectionKind, AgentHostModelTelemetryKind } from './agentHostTelemetryReporter.js'; import { getCodexAccountTelemetryData } from './codex/codexAccountTelemetry.js'; export interface IAgentHostTurnTelemetryContext { readonly model: string | undefined; readonly modelTelemetryKind: AgentHostModelTelemetryKind | undefined; - readonly modelSelectionKind: 'default' | 'auto' | 'explicit'; + readonly modelSelectionKind: AgentHostModelSelectionKind; readonly permissionLevel: string | undefined; readonly interactionMode: SessionMode | undefined; } @@ -42,14 +43,24 @@ export function getTurnTelemetryContext(agent: IAgent, chat: ProtocolURI, contex const permissionValue = state?.config?.values[SessionConfigKey.AutoApprove]; const permissionLevel = typeof permissionValue === 'string' ? permissionValue : undefined; const interactionMode = getConfiguredSessionMode(state?.config); - const modelSelectionKind = modelId === undefined ? 'default' : modelId === 'auto' ? 'auto' : 'explicit'; const effectiveModelId = modelId ?? agent.chats.getModel?.(URI.parse(chat), context)?.id; + const modelSelectionKind = getModelSelectionKind(agent.id, modelId, effectiveModelId); const modelContext = effectiveModelId === undefined || (modelId === undefined && effectiveModelId === 'auto') ? { model: undefined, modelTelemetryKind: undefined } : getModelTelemetryContext(agent, effectiveModelId); return { ...modelContext, modelSelectionKind, permissionLevel, interactionMode }; } +function getModelSelectionKind(provider: string, modelId: string | undefined, effectiveModelId = modelId): AgentHostModelSelectionKind { + if (provider === COPILOT_CLI_AGENT_PROVIDER_ID && effectiveModelId === COPILOT_HYDRA_FUSION_MODEL_ID) { + return 'hydrafusion'; + } + if (effectiveModelId === 'auto') { + return 'auto'; + } + return modelId === undefined ? 'default' : 'explicit'; +} + export function getModelTelemetryContext(agent: IAgent, modelId: string): { model: string; modelTelemetryKind: AgentHostModelTelemetryKind } { const model = agent.models.get().find(model => model.id === modelId); let modelTelemetryKind: AgentHostModelTelemetryKind; diff --git a/src/vs/platform/agentHost/node/agentHostTurnTracker.ts b/src/vs/platform/agentHost/node/agentHostTurnTracker.ts index d037acf7619bd9..b765a851de073e 100644 --- a/src/vs/platform/agentHost/node/agentHostTurnTracker.ts +++ b/src/vs/platform/agentHost/node/agentHostTurnTracker.ts @@ -6,13 +6,13 @@ import { disposableTimeout } from '../../../base/common/async.js'; import { getErrorMessage } from '../../../base/common/errors.js'; import { Emitter, Event } from '../../../base/common/event.js'; -import { Disposable, DisposableMap, toDisposable } from '../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, type IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { StopWatch } from '../../../base/common/stopwatch.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import { URI } from '../../../base/common/uri.js'; import type { AgentModelCallFinishedOutcome, AgentSubagentKind, AgentSubagentTaskModelSource, IAgent, IAgentTelemetryContext, IAgentTokenUsageSummary, IAgentTurnDiagnosticSnapshot, IAgentTurnTokenUsage } from '../common/agent.js'; import type { SessionMode } from '../common/agentHostSchema.js'; -import { createUnknownAgentHostClientTelemetryContext, type AgentHostProviderSendStage, type IAgentHostClientTelemetryContext, type IAgentProviderSendStageRecorder, type IAgentProviderTurnTelemetryContext } from '../common/agentHostTelemetry.js'; +import { type CodexModelProvider, createUnknownAgentHostClientTelemetryContext, type AgentHostProviderSendStage, type IAgentHostClientTelemetryContext, type IAgentProviderSendStageRecorder, type IAgentProviderTurnTelemetryContext } from '../common/agentHostTelemetry.js'; import { AgentHostClientType } from '../common/agentHostClientInfo.js'; import { IAgentHostClientConnectionService } from './agentHostClientConnectionService.js'; import { ILogService } from '../../log/common/log.js'; @@ -21,7 +21,7 @@ import { captureProviderTurnTelemetryContext, getModelTelemetryContext } from '. import { canRefineContributor, toolSourceKindFromContributor } from './shared/toolCallContributor.js'; import { SessionInputRequestKind } from '../common/state/protocol/state.js'; import { isSubagentChatUri, isSubagentSession, parseChatUri, type ITurnTokenTotal, type ToolCallContributor } from '../common/state/sessionState.js'; -import { IAgentHostTelemetryReporter, type AgentHostInitiatorClientConnectionState, type AgentHostMessageOriginTelemetryKind, type AgentHostModelTelemetryKind, type AgentHostProviderDiagnosticState, type AgentHostTelemetryReporter, type AgentHostTurnFailureStage, type AgentHostTurnHangReason, type AgentHostTurnResult, type AgentHostTurnSendStage, type IAgentHostTurnFailure } from './agentHostTelemetryReporter.js'; +import { IAgentHostTelemetryReporter, type AgentHostInitiatorClientConnectionState, type AgentHostMessageOriginTelemetryKind, type AgentHostModelSelectionKind, type AgentHostModelTelemetryKind, type AgentHostProviderDiagnosticState, type AgentHostTelemetryReporter, type AgentHostTurnFailureStage, type AgentHostTurnHangReason, type AgentHostTurnResult, type AgentHostTurnSendStage, type IAgentHostTurnFailure } from './agentHostTelemetryReporter.js'; /** * How long a turn must go without any observed activity before the watchdog @@ -76,7 +76,7 @@ interface ITurnTiming { modelTelemetryKind: AgentHostModelTelemetryKind | undefined; readonly selectedModel: string | undefined; readonly selectedModelTelemetryKind: AgentHostModelTelemetryKind | undefined; - readonly modelSelectionKind: 'default' | 'auto' | 'explicit'; + readonly modelSelectionKind: AgentHostModelSelectionKind; readonly permissionLevel: string | undefined; readonly interactionMode: SessionMode | undefined; /** Who produced the message that started the turn, when known. */ @@ -86,6 +86,7 @@ interface ITurnTiming { readonly clientContext: IAgentHostClientTelemetryContext; telemetryContext: IAgentTelemetryContext | undefined; readonly providerTelemetryContext: IAgentProviderTurnTelemetryContext | undefined; + codexModelProvider?: CodexModelProvider; readonly initiatorClientId: string | undefined; readonly completedModelCallIds: Set; readonly finishedModelCallIds: Set; @@ -161,9 +162,47 @@ interface ITurnUsage { * later completes, it also reports `agentHost.hungTurnCompleted` so permanent * hangs can be separated from merely slow ones. */ -export const IAgentHostTurnTracker = createDecorator('agentHostTurnTracker'); +export const IAgentHostTurnTracker = createDecorator('agentHostTurnTracker'); + +export interface IAgentHostTurnTracker extends IDisposable { + readonly _serviceBrand: undefined; + readonly onDidStartTurn: Event; + readonly onDidDispatchTurn: Event<{ readonly chat: string; readonly turnId: string }>; + turnStarted(agent: IAgent, session: string, turnId: string, model: string | undefined, modelTelemetryKind: AgentHostModelTelemetryKind | undefined, modelSelectionKind: AgentHostModelSelectionKind, permissionLevel: string | undefined, interactionMode: SessionMode | undefined, clientContext?: IAgentHostClientTelemetryContext, initiatorClientId?: string, parentTurnId?: string, parentToolCallId?: string, messageOriginKind?: AgentHostMessageOriginTelemetryKind, subagentTaskModelSource?: AgentSubagentTaskModelSource, providerChat?: URI, subagentKind?: AgentSubagentKind): void; + setTitleGenerationStrategy(session: string, turnId: string, strategy: AutomaticTitleGenerationStrategy): void; + markFirstProgress(session: string, turnId: string): void; + markFirstSubstantiveProgress(session: string, turnId: string): void; + markSendStage(session: string, turnId: string, stage: AgentHostTurnSendStage): void; + markSendDispatched(session: string, turnId: string): void; + markProviderStage(session: string, turnId: string, stage: AgentHostProviderSendStage): void; + createProviderStageRecorder(session: string, turnId: string): IAgentProviderSendStageRecorder; + markActivity(session: string, turnId: string, activityKind: string): void; + setCurrentStage(session: string, turnId: string, stage: AgentHostTurnFailureStage): void; + toolCallStarted(session: string, turnId: string, toolCallId: string, toolName: string, contributor: ToolCallContributor | undefined): void; + toolCallMetadataUpdated(session: string, turnId: string, toolCallId: string, contributor: ToolCallContributor | undefined): void; + toolCallEnded(session: string, turnId: string, toolCallId: string): void; + turnBlocked(session: string, turnId: string, requestId: string, kind: SessionInputRequestKind, toolCallId: string | undefined): void; + turnUnblocked(session: string, requestId: string): void; + updateModel(session: string, turnId: string, model: string, modelTelemetryKind: AgentHostModelTelemetryKind): void; + updateBilledNanoAiu(session: string, turnId: string, billedNanoAiu: number | undefined): void; + updateDirectUsage(session: string, turnId: string, tokenTotals: readonly ITurnTokenTotal[] | undefined, billedNanoAiu: number | undefined): void; + modelCallCompleted(session: string, turnId: string, modelCallId: string): void; + markSteering(session: string, turnId: string, kind: 'started' | 'received'): void; + modelCallFinished(session: string, turnId: string, modelCallId: string, dispatchDurationMs: number, outcome: AgentModelCallFinishedOutcome, containsBuiltInFileEditRequest: boolean | undefined, editClassifierVersion: number): void; + getModelTelemetryContext(session: string, turnId: string): { model: string | undefined; modelTelemetryKind: AgentHostModelTelemetryKind | undefined } | undefined; + getClientTelemetryContext(session: string, turnId: string): IAgentHostClientTelemetryContext | undefined; + getTelemetryContext(session: string, turnId: string): IAgentTelemetryContext | undefined; + setCodexModelProvider(session: string, turnId: string, provider: CodexModelProvider): void; + getProviderTelemetryContext(session: string, turnId: string): IAgentProviderTurnTelemetryContext | undefined; + getSubagentKind(session: string, turnId: string): AgentSubagentKind | undefined; + getMessageOriginKind(session: string, turnId: string): AgentHostMessageOriginTelemetryKind | undefined; + getInitiatorClientId(session: string, turnId: string): string | undefined; + turnCompleted(session: string, turnId: string, result: AgentHostTurnResult, failure?: IAgentHostTurnFailure, workspace?: { readonly isMultiRoot: boolean; readonly folderCount: number }): boolean; + clearSession(session: string): void; + clearTurnsExcept(session: string, keepTurnIds: ReadonlySet): void; +} -export class AgentHostTurnTracker extends Disposable { +export class AgentHostTurnTracker extends Disposable implements IAgentHostTurnTracker { private _hostRootTurnOrdinal = 0; declare readonly _serviceBrand: undefined; @@ -210,7 +249,7 @@ export class AgentHostTurnTracker extends Disposable { })); } - turnStarted(agent: IAgent, session: string, turnId: string, model: string | undefined, modelTelemetryKind: AgentHostModelTelemetryKind | undefined, modelSelectionKind: 'default' | 'auto' | 'explicit', permissionLevel: string | undefined, interactionMode: SessionMode | undefined, clientContext = createUnknownAgentHostClientTelemetryContext(AgentHostClientType.Unknown), initiatorClientId?: string, parentTurnId?: string, parentToolCallId?: string, messageOriginKind?: AgentHostMessageOriginTelemetryKind, subagentTaskModelSource?: AgentSubagentTaskModelSource, providerChat = URI.parse(session), subagentKind?: AgentSubagentKind): void { + turnStarted(agent: IAgent, session: string, turnId: string, model: string | undefined, modelTelemetryKind: AgentHostModelTelemetryKind | undefined, modelSelectionKind: AgentHostModelSelectionKind, permissionLevel: string | undefined, interactionMode: SessionMode | undefined, clientContext = createUnknownAgentHostClientTelemetryContext(AgentHostClientType.Unknown), initiatorClientId?: string, parentTurnId?: string, parentToolCallId?: string, messageOriginKind?: AgentHostMessageOriginTelemetryKind, subagentTaskModelSource?: AgentSubagentTaskModelSource, providerChat = URI.parse(session), subagentKind?: AgentSubagentKind): void { const key = this._key(session, turnId); let rootTiming = this._rootTurnTimings.get(key); const isNewRootTurn = !parentTurnId && !isSubagentChatUri(session) && !isSubagentSession(parseChatUri(session)?.session ?? session) && !rootTiming; @@ -641,6 +680,13 @@ export class AgentHostTurnTracker extends Disposable { return this._turnTimings.get(this._key(session, turnId))?.telemetryContext; } + setCodexModelProvider(session: string, turnId: string, provider: CodexModelProvider): void { + const timing = this._turnTimings.get(this._key(session, turnId)); + if (timing?.agent.id === 'codex') { + timing.codexModelProvider = provider; + } + } + getProviderTelemetryContext(session: string, turnId: string): IAgentProviderTurnTelemetryContext | undefined { return this._turnTimings.get(this._key(session, turnId))?.providerTelemetryContext; } @@ -699,6 +745,7 @@ export class AgentHostTurnTracker extends Disposable { clientContext: timing.clientContext, telemetryContext: timing.telemetryContext, providerTelemetryContext: timing.providerTelemetryContext, + codexModelProvider: timing.codexModelProvider, provider: timing.agent.id, session: timing.session, turnId, diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index a6eea10c13d132..0d3d89bb08abd6 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -23,7 +23,7 @@ import { localize } from '../../../nls.js'; import { FileChangeType, FileOperationResult, IFileChange, IFileService, toFileOperationResult, type FileChangesEvent } from '../../files/common/files.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; 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, IAgentLegacyChat, IAgentMaterializeChatEvent, IAgentModelInfo, type IAgentPluginUninstallRequest, IAgentResolveSessionConfigParams, IAgentChatAdoptionResult, type AgentChatAdoptionReason, IAgentSessionConfigCompletionsParams, type IAgentSessionChatMetadata, IAgentSessionMetadata, IAgentSpawnChatEvent, AuthenticateParams, AuthenticateResult, SubagentChatSignal, subagentChatTitle } from '../common/agent.js'; +import { AgentChatMigrationDeferred, AgentProvider, AgentSession, CLAUDE_AGENT_PROVIDER_ID, COPILOT_CLI_AGENT_PROVIDER_ID, AgentSignal, CODEX_AGENT_PROVIDER_ID, IAgent, type IAgentAdoptedWorktree, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, IAgentCreateChatOptions, IAgentCreateChatRequestOptions, IAgentCreateChatResult, IAgentCreateChatSideChatSelection, IAgentCreateChatSideChatSource, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDiscoveredChat, IAgentLegacyChat, IAgentMaterializeChatEvent, IAgentModelInfo, type IAgentPluginUninstallRequest, IAgentResolveSessionConfigParams, IAgentChatAdoptionResult, type AgentChatAdoptionReason, IAgentSessionConfigCompletionsParams, type IAgentSessionChatMetadata, 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 { ISessionDatabase, ISessionDataService, ISessionStorageAccessCounts, SESSION_ATTACHMENTS_DIRNAME } from '../common/sessionDataService.js'; import { IAgentEditAttributionService, ICancelEditAttributionFlushParams, ICommitEditAttributionFlushParams, IEditAttributionFlushResult, IPrepareEditAttributionFlushParams, IPreparedEditAttributionFlush, parseEditAttributionResource } from '../common/fileEditAttribution.js'; @@ -49,6 +49,7 @@ import { IAgentMessageDelegationMeta, toAgentMessageDelegationMeta } from '../co import { toAgentMergeMessageMeta } from '../common/meta/agentMergeMessageMeta.js'; import { readChatSurfaceMeta, withChatSurfaceMeta } from '../common/meta/agentChatSurfaceMeta.js'; import { AH_META_DEV_CONTAINER_WORKTREE_DB_KEY, readAgentDevContainerWorktreeMetadata, withAgentDevContainerWorktreeMetadata } from '../common/meta/agentDevContainerWorktreeMeta.js'; +import { readCodexSessionModel, withCodexSessionModel } from '../common/meta/codexSessionModel.js'; import { IRemoteSessionOrigin, parseRemoteSessionOrigin, readRemoteSessionOrigin, REMOTE_SESSION_ORIGIN_METADATA_KEY, withRemoteSessionOrigin } from '../common/meta/agentRemoteSessionMeta.js'; import { getLegacySessionInitiator, parseSessionInitiator, readSessionInitiator, SESSION_INITIATOR_METADATA_KEY, withSessionInitiator } from '../common/meta/agentSessionInitiatorMeta.js'; import { AgentConfigurationService, getEffectiveWorkingDirectories } from './agentConfigurationService.js'; @@ -66,17 +67,18 @@ import { createAgentChatContext } from './agentChatContext.js'; import { AgentHostDebugLogsCollector, type IAgentHostDebugLogsEnvironment } from './agentHostDebugLogs.js'; import { IAgentHostDatabase, IAgentHostDatabaseSessionOptions, type IAgentHostDatabaseSessionsV2Exclusion } from './agentHostDatabase.js'; import { AgentSessionRegistry, IRegisteredSession, IStoredRegisteredSession } from './agentSessionRegistry.js'; -import { IAgentHostGitService, tryResolvePrimaryWorktreeRoot } from '../common/agentHostGitService.js'; +import { IAgentHostGitService, META_DIFF_BASE_BRANCH, tryResolvePrimaryWorktreeRoot } from '../common/agentHostGitService.js'; import { IAgentHostSubscriptionService, resolveAgentHostSession } from '../common/agentHostSubscriptionService.js'; import { IAgentHostChatInputService } from './agentHostChatInputService.js'; import { AgentSideEffects, type IAgentSideEffectsOptions } from './agentSideEffects.js'; import { AgentHostLocalTurns, parsePersistedTurn } from './agentHostLocalTurns.js'; +import { hasSingleUserChat, ISessionWorkspaceConversionService } from './chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.js'; import { AgentSessionResidency } from './agentSessionResidency.js'; import { resolveChangesetOwnerScope } from './agentHostBranchChangesetScope.js'; import { IAgentHostSessionOpenTelemetry, type IAgentHostSessionOpenTelemetryScope } from './agentHostSessionOpenTelemetry.js'; import { AgentServerToolHost } from './shared/agentServerToolHost.js'; import { type IAddSessionWorkingDirectoryOptions, type IAgentServiceSessionServerToolAccessor, type IPreparedChatWorkingDirectory, type IChatContextSnapshot, type IRenameTitleResult, type ISessionCreationDefaults, validateRenameTitle } from './shared/sessionServerTools.js'; -import { AGENT_HOST_TITLE_SOURCE_AGENT, AGENT_HOST_TITLE_SOURCE_AUTO, customChatTitleMetadataKey, customChatTitleSourceMetadataKey, persistSessionMetadataValues, SESSION_ARTIFACTS_KEY, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY } from './shared/persistSessionMetadata.js'; +import { AGENT_HOST_TITLE_SOURCE_AGENT, AGENT_HOST_TITLE_SOURCE_AUTO, customChatTitleMetadataKey, customChatTitleSourceMetadataKey, parseSessionWorkingDirectories, persistSessionMetadataValues, SESSION_ARTIFACTS_KEY, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY, SESSION_WORKING_DIRECTORIES_KEY } from './shared/persistSessionMetadata.js'; import { type IArtifactServerToolAccessor } from './shared/artifactServerTools.js'; import { SessionArtifacts } from './shared/sessionArtifacts.js'; import { readSessionAdditionalWorktrees, writeSessionAdditionalWorktrees, type ISessionAdditionalWorktree } from './shared/sessionAdditionalWorktrees.js'; @@ -101,6 +103,8 @@ import { ICopilotApiService } from './shared/copilotApiService.js'; import { INetworkDiagnosticsService } from './networkDiagnosticsService.js'; import { toAgentClientUri } from '../common/agentClientUri.js'; import { AgentHostClientType } from '../common/agentHostClientInfo.js'; +import { IAgentHostClientConnectionService } from './agentHostClientConnectionService.js'; +import { IAgentHostTurnTracker } from './agentHostTurnTracker.js'; import { resolveLastNonLocalTurnId } from '../common/agentHostConversationContext.js'; import { AgentHostLaunchKind, createUnknownAgentHostClientTelemetryContext, type IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; import { IAgentHostGitHubEndpointService } from './agentHostGitHubEndpointService.js'; @@ -613,6 +617,7 @@ export class AgentService extends Disposable implements IAgentService { private readonly _resourceWriteQueue = this._register(new ResourceQueue()); private readonly _chatCatalogMutationSequencer = new SequencerByKey(); + private readonly _sessionCreationSequencer = new SequencerByKey(); private readonly _additionalWorktreeSequencer = new SequencerByKey(); /** Protocol: fires when state is mutated by an action. */ @@ -801,10 +806,13 @@ export class AgentService extends Disposable implements IAgentService { @IAgentHostWorktreeIsolation private readonly _worktree: IAgentHostWorktreeIsolation, @IAgentHostProviderService private readonly _providerService: IAgentHostProviderService, @IAgentHostTurnService private readonly _turnService: IAgentHostTurnService, + @ISessionWorkspaceConversionService private readonly _workspaceConversionService: ISessionWorkspaceConversionService, @IAgentHostOTelService private readonly _otelService: IAgentHostOTelService, @IAgentHostSessionTitleController private readonly _titleController: IAgentHostSessionTitleController, @IAdditionalWorktreeLifecycleService private readonly _additionalWorktreeLifecycleService: IAdditionalWorktreeLifecycleService, @IAgentHostStartupPerformance private readonly _startupPerformance: IAgentHostStartupPerformance, + @IAgentHostClientConnectionService private readonly _clientConnections: IAgentHostClientConnectionService, + @IAgentHostTurnTracker private readonly _turnTracker: IAgentHostTurnTracker, ) { super(); this._authService = core.authenticationService; @@ -1052,7 +1060,7 @@ export class AgentService extends Disposable implements IAgentService { const readableProviders = new Set(); for (const session of sessions) { this._provisionalSessionKeys.add(session); - const provider = AgentSession.provider(session); + const provider = this._providerService.getProviderForSession(session)?.id; if (provider && this._readableProviderCatalogs.has(provider)) { readableProviders.add(provider); } @@ -1187,14 +1195,19 @@ export class AgentService extends Disposable implements IAgentService { async whenCatalogReconciliationIdle(): Promise { while (true) { - await this._catalogReconciliationService.whenIdle(); - if (this._backgroundCatalogStateWrites.size === 0 && this._backgroundPassiveSessionMetadataWrites.size === 0) { - return; + try { + await this._catalogReconciliationService.whenIdle(); + if (this._backgroundCatalogStateWrites.size === 0 && this._backgroundPassiveSessionMetadataWrites.size === 0) { + return; + } + } finally { + while (this._backgroundCatalogStateWrites.size > 0 || this._backgroundPassiveSessionMetadataWrites.size > 0) { + await Promise.allSettled([ + ...[...this._backgroundCatalogStateWrites.values()].map(write => write.promise), + ...[...this._backgroundPassiveSessionMetadataWrites.values()].map(write => write.promise), + ]); + } } - await Promise.allSettled([ - ...[...this._backgroundCatalogStateWrites.values()].map(write => write.promise), - ...[...this._backgroundPassiveSessionMetadataWrites.values()].map(write => write.promise), - ]); } } @@ -1497,8 +1510,7 @@ export class AgentService extends Disposable implements IAgentService { return { getAutomaticTitleGenerationStrategy: session => this._titleController.getAutomaticTitleGenerationStrategy(session), isWorkspaceless, - canConvertWorkspace: session => this._providerService.getProviderForSession(session)?.agentHostCapabilities.workspaceConversion === true - && isWorkspaceless(session), + canConvertWorkspace: session => this._providerService.getProviderForSession(session)?.agentHostCapabilities.workspaceConversion === true, supportsChatWorkingDirectories: session => !!this._providerService.getProviderForSession(session)?.getDescriptor().capabilities?.multipleWorkingDirectories, listSessions: () => this.listSessions(), getSession: session => this._getSessionMetadata(session), @@ -1691,7 +1703,7 @@ export class AgentService extends Disposable implements IAgentService { private async _cancelAutomationSession(session: URI): Promise { const chat = buildDefaultChatUri(session); const activeTurn = this._stateManager.getChatState(chat)?.activeTurn; - if (!activeTurn) { + if (!activeTurn || this._workspaceConversionService.isConversionTurn(chat, activeTurn.id)) { return false; } const startedAt = Date.parse(activeTurn.startedAt); @@ -1885,18 +1897,19 @@ export class AgentService extends Disposable implements IAgentService { return persisted?.some(candidate => candidate.uri === chat.toString()) === true; } - private _toSessionMetadata(metadata: IAgentChatMetadata): IAgentSessionMetadata { + private _toSessionMetadata(metadata: IAgentChatMetadata, provider?: AgentProvider): IAgentSessionMetadata { const { chat, changesets: _changesets, ...rest } = metadata; return { ...rest, session: URI.parse(parseRequiredSessionUriFromChatUri(chat)), + provider: provider ?? this._providerService.getProviderForSession(parseRequiredSessionUriFromChatUri(chat))?.id, }; } /** `undefined` means the provider catalog is unavailable; deferred waits for external readiness. */ private async _enumerateLegacyProviderSessions(provider: IAgent): Promise { const chats = await provider.listChatsToMigrate(); - return chats === AgentChatMigrationDeferred ? chats : chats?.map(metadata => this._toSessionMetadata(metadata)); + return chats === AgentChatMigrationDeferred ? chats : chats?.map(metadata => this._toSessionMetadata(metadata, provider.id)); } /** @@ -1921,7 +1934,7 @@ export class AgentService extends Disposable implements IAgentService { // in-flight computation into a redundant second pass. await this._advanceSessionModifiedTime(session, metadata.modifiedTime, false); } - const sessionMetadata = this._toSessionMetadata(metadata); + const sessionMetadata = this._toSessionMetadata(metadata, agent.id); return { ...sessionMetadata, _meta: withSessionExternal(sessionMetadata._meta, external), @@ -2033,12 +2046,17 @@ export class AgentService extends Disposable implements IAgentService { ? { customTitle: true, configValues: true, [defaultChatTitleKey]: true, [AH_META_IS_READ_DB_KEY]: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, [AH_META_CREATED_BY_SESSION_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [AH_META_EHCLI_ADOPTED_DB_KEY]: true, [AH_META_DEV_CONTAINER_WORKTREE_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, [SESSION_ARTIFACTS_KEY]: true, [CHAT_BACKING_METADATA_KEY]: true, [WORKTREE_META_REPOSITORY_ROOT]: true, ...GIT_DB_METADATA_KEYS, ...changesetKeys } : { customTitle: true, [defaultChatTitleKey]: true, [AH_META_IS_READ_DB_KEY]: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, [AH_META_CREATED_BY_SESSION_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [AH_META_EHCLI_ADOPTED_DB_KEY]: true, [AH_META_DEV_CONTAINER_WORKTREE_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, [SESSION_ARTIFACTS_KEY]: true, [CHAT_BACKING_METADATA_KEY]: true, [WORKTREE_META_REPOSITORY_ROOT]: true, ...GIT_DB_METADATA_KEYS }; metadataKeys[REMOTE_SESSION_ORIGIN_METADATA_KEY] = true; + metadataKeys[SESSION_WORKING_DIRECTORIES_KEY] = true; metadataKeys[SESSION_INITIATOR_METADATA_KEY] = true; const persisted = await ref.object.getMetadataObject(metadataKeys); if (persisted[CHAT_BACKING_METADATA_KEY]) { return undefined; } let updated = sanitized; + const workingDirectories = parseSessionWorkingDirectories(persisted[SESSION_WORKING_DIRECTORIES_KEY]); + if (workingDirectories !== undefined) { + updated = { ...updated, workingDirectories: workingDirectories.map(directory => URI.parse(directory)) }; + } const persistedTitle = persisted.customTitle || await this._readDefaultChatTitle(metadata.session, persisted[defaultChatTitleKey]); if (persistedTitle) { @@ -2170,6 +2188,7 @@ export class AgentService extends Disposable implements IAgentService { _meta = withSessionMultiRootMetadata(_meta, liveMultiRoot ?? readSessionMultiRootMetadata(metadata._meta)); return { ...metadata, + provider: liveSummary.provider, summary: trustLiveTitle ? liveSummary.title || metadata.summary : metadata.summary || liveSummary.title, status: liveSummary.status, activity: liveSummary.activity, @@ -2804,11 +2823,20 @@ export class AgentService extends Disposable implements IAgentService { private async _registerDiscoveredChats(provider: IAgent, chats: readonly IAgentDiscoveredChat[], awaitReconciliation = true, observation?: IDiscoveryRegistrationObservation): Promise { // Keys only: discovery arrives in batches, and the full listing re-runs the // per-row provenance migration for every registered session each time. - const [runtimeCompatibleKeys, registeredRecency, persistedExclusions] = await Promise.all([ + const discoveredSessionKeys = provider.id === CODEX_AGENT_PROVIDER_ID + ? chats.map(chat => parseRequiredSessionUriFromChatUri(chat.chat)) + : []; + const [runtimeCompatibleKeys, registeredRecency, persistedExclusions, codexCatalogRows] = await Promise.all([ this._sessionRegistry.listRuntimeCompatibleSessionKeys(), this._sessionRegistry.listSessionModifiedTimes(), this._sessionRegistry.listSessionsV2Exclusions(provider.id), + discoveredSessionKeys.length > 0 ? this._orchestratorDatabase.listSessionsV2(discoveredSessionKeys) : [], ]); + const codexCatalogModels = new Map(); + for (const row of codexCatalogRows) { + const decoded = decodeAgentHostCatalogPayload(row.payload); + codexCatalogModels.set(row.session, decoded.ok ? readCodexSessionModel(decoded.value.data)?.id : undefined); + } const registeredKeys = new Set(runtimeCompatibleKeys); const exclusions = new Map(persistedExclusions.map(exclusion => [exclusion.session, exclusion])); const exclusionsToMark: IAgentHostDatabaseSessionsV2Exclusion[] = []; @@ -2831,7 +2859,7 @@ export class AgentService extends Disposable implements IAgentService { const untitledExternal: IAgentSessionMetadata[] = []; const modifiedTimeAdvances: { readonly session: URI; readonly modifiedTime: number }[] = []; const results = await Promise.all(chats.map(({ external: reportedExternal, ...metadata }) => discoveryLimiter.queue(async () => { - const sessionMetadata = this._toSessionMetadata(metadata); + const sessionMetadata = this._toSessionMetadata(metadata, provider.id); const session = sessionMetadata.session; try { // Registered identities retain provenance; only changed restored titles need per-session I/O. @@ -2849,7 +2877,9 @@ export class AgentService extends Disposable implements IAgentService { && !equals(surfaced?.project, { uri: sessionMetadata.project.uri.toString(), displayName: sessionMetadata.project.displayName }); const metaChanged = sessionMetadata._meta !== undefined && !equals(surfaced?._meta, { ...surfaced?._meta, ...sessionMetadata._meta }); - if (surfaced && (titleChanged || directoriesChanged || projectChanged || metaChanged)) { + const codexModelChanged = provider.id === CODEX_AGENT_PROVIDER_ID + && codexCatalogModels.get(session.toString()) !== readCodexSessionModel(sessionMetadata)?.id; + if (codexModelChanged || (surfaced && (titleChanged || directoriesChanged || projectChanged || metaChanged))) { surfacedMetadataChanged = true; changedMetadataSessions.push(session.toString()); } @@ -3231,6 +3261,10 @@ export class AgentService extends Disposable implements IAgentService { [AH_META_IS_READ_DB_KEY]: String(data.isRead), [AH_META_IS_ARCHIVED_DB_KEY]: String(data.isArchived), }; + // An empty cached list means "not known yet" and must not mask the provider's folders. + if (data.workingDirectories.length > 0) { + metadata[SESSION_WORKING_DIRECTORIES_KEY] = JSON.stringify(data.workingDirectories); + } if (data.summary !== undefined) { metadata[SESSION_CUSTOM_TITLE_KEY] = data.summary; } @@ -3323,7 +3357,7 @@ export class AgentService extends Disposable implements IAgentService { const metadata = await ref.object.getMetadataObject({ [CHAT_BACKING_METADATA_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true }); // The workspace-less marker is written when the host creates a session, // so its presence is what identifies a host-created session. - return { chatBacking: !!metadata[CHAT_BACKING_METADATA_KEY], hostCreated: metadata[AH_META_WORKSPACELESS_DB_KEY] !== undefined }; + return { chatBacking: !!metadata[CHAT_BACKING_METADATA_KEY] && await this._isChatBacking(session), hostCreated: metadata[AH_META_WORKSPACELESS_DB_KEY] !== undefined }; } finally { ref.dispose(); } @@ -3345,7 +3379,12 @@ export class AgentService extends Disposable implements IAgentService { private _listRegisteredSessions(): Promise { if (!this._inFlightRegisteredSessions) { - const operation = this._sessionRegistry.list(entry => this._migrateRegisteredSession(entry)); + const operation = this._sessionRegistry.list(entry => this._migrateRegisteredSession(entry)).then(sessions => { + for (const session of sessions) { + this._providerService.associateSession(session.session, session.provider); + } + return sessions; + }); const inFlight = operation.finally(() => { if (this._inFlightRegisteredSessions === inFlight) { this._inFlightRegisteredSessions = undefined; @@ -3515,7 +3554,9 @@ export class AgentService extends Disposable implements IAgentService { const known = new Set(); for (const session of sessions) { const key = session.toString(); - if (registered.has(key)) { + const provider = AgentSession.provider(session); + const exclusion = provider ? await this._sessionRegistry.getSessionsV2Exclusion(provider, session) : undefined; + if (registered.has(key) || exclusion?.reason === 'backing' || await this._isChatBacking(session)) { known.add(key); } } @@ -3536,8 +3577,16 @@ export class AgentService extends Disposable implements IAgentService { const ref = await this._sessionDataService.tryOpenDatabase(session); if (ref) { try { - if (await ref.object.getMetadata(CHAT_BACKING_METADATA_KEY)) { - return true; + const marker = await ref.object.getMetadata(CHAT_BACKING_METADATA_KEY); + if (marker) { + const owningSession = parseChatUri(marker)?.session; + const legacyProvider = AgentSession.provider(session); + const provider = await ref.object.getMetadata(`${CHAT_BACKING_METADATA_KEY}Provider`) + ?? (owningSession ? this._providerService.getProviderForSession(owningSession)?.id : undefined) + ?? (marker === 'true' && legacyProvider ? this._providerService.getProviderForSession(session)?.id : undefined); + if (provider !== undefined && provider === legacyProvider) { + return true; + } } } finally { ref.dispose(); @@ -4360,7 +4409,7 @@ export class AgentService extends Disposable implements IAgentService { private async _refreshPublishedSessionList(provider: AgentProvider): Promise { await this._whenProvisionalSessionKeysLoaded(); - const provisional = [...this._provisionalSessionKeys].filter(session => AgentSession.provider(session) === provider); + const provisional = [...this._provisionalSessionKeys].filter(session => this._providerService.getProviderForSession(session)?.id === provider); if (provisional.length === 0) { return; } @@ -4370,7 +4419,7 @@ export class AgentService extends Disposable implements IAgentService { } this._invalidateSessionList(); const visible = new Set((await this.listSessions()) - .filter(metadata => AgentSession.provider(metadata.session) === provider) + .filter(metadata => metadata.provider === provider) .map(metadata => metadata.session.toString())); for (const session of this._stateManager.getExposedSessionKeys().filter(session => provisional.includes(session))) { if (!visible.has(session)) { @@ -4422,7 +4471,7 @@ export class AgentService extends Disposable implements IAgentService { if (this._stateManager.getSessionState(key)) { this._stateManager.setSessionSummaryPublished(key, true); } else { - const provider = AgentSession.provider(metadata.session); + const provider = metadata.provider ?? this._providerService.getProviderForSession(metadata.session)?.id; if (provider) { await this._announceSurfacedSession(metadata, provider); } @@ -4598,11 +4647,42 @@ export class AgentService extends Disposable implements IAgentService { } async createSession(config?: IAgentCreateSessionConfig): Promise { + const provider = this._providerService.resolveProvider(config?.provider); + if (provider && config?.session && this._providerService.getProviderForSession(config.session) === provider) { + this._cancelPendingSessionGc(config.session); + } + return config?.session + ? this._sessionCreationSequencer.queue(AgentSession.id(config.session), () => this._createSession(config)) + : this._createSession(config); + } + + private async _createSession(config?: IAgentCreateSessionConfig): Promise { const provider = this._providerService.resolveProvider(config?.provider); const isEphemeral = config ? readEphemeralSessionMeta(config).isEphemeral === true : false; if (!provider) { throw new Error(`No agent provider registered for: ${config?.provider ?? '(none)'}`); } + if (config?.session?.scheme === 'ahp-session') { + const requested = config.session; + const registered = await this._orchestratorDatabase.getSessionV2Registration(requested.toString()); + if (registered && registered.provider !== provider.id) { + throw new Error(`Session ${requested.toString()} is already owned by ${registered.provider}`); + } + if (!registered) { + const keys = await this._sessionRegistry.listRuntimeCompatibleSessionKeys(); + if ([...keys].some(key => AgentSession.id(key) === AgentSession.id(requested)) + || await this._fileService.exists(this._sessionDataService.getSessionDataDir(requested)) + || await this._sessionRegistry.isTombstoned(requested)) { + throw new Error(`Session storage identity is already in use: ${requested.toString()}`); + } + } + } else if (config?.session && !await this._orchestratorDatabase.getSessionV2Registration(config.session.toString())) { + const requestedId = AgentSession.id(config.session); + const keys = await this._sessionRegistry.listRuntimeCompatibleSessionKeys(); + if ([...keys].some(key => URI.parse(key).scheme === 'ahp-session' && AgentSession.id(key) === requestedId)) { + throw new Error(`Session storage identity is already in use: ${config.session.toString()}`); + } + } if (config?.session) { this._cancelPendingSessionGc(config.session); this._sessionResidency.touch(config.session); @@ -4934,6 +5014,10 @@ export class AgentService extends Disposable implements IAgentService { async createChat(session: URI, chat: URI, options?: IAgentCreateChatRequestOptions): Promise { const sessionKey = session.toString(); + const initialPrimaryDirectory = this._stateManager.getSessionSummary(sessionKey)?.workingDirectories?.[0]; + if (this._workspaceConversionService.isPending(buildDefaultChatUri(session), true)) { + throw new Error('Wait for workspace setup to finish before creating another chat.'); + } const creationError = this._worktree.getCreationError(AgentSession.id(session)); if (creationError) { throw creationError; @@ -5050,6 +5134,10 @@ export class AgentService extends Disposable implements IAgentService { } const createResult = await this._chatCatalogMutationSequencer.queue(sessionKey, async () => { + if (this._workspaceConversionService.isPending(buildDefaultChatUri(session), true) + || this._stateManager.getSessionSummary(sessionKey)?.workingDirectories?.[0] !== initialPrimaryDirectory) { + throw new Error('The session workspace changed while preparing the new chat. Try creating the chat again.'); + } // Create the backing chat before publishing `session/chatAdded` so // subscribers only see a chat that can already receive messages. const createResult = await this._createChat(provider, chat, session, createOptions); @@ -5338,8 +5426,24 @@ export class AgentService extends Disposable implements IAgentService { } /** Mints the session URI before the collapsed `createChat` path derives its default-chat URI. */ - private _mintSessionUri(provider: IAgent): URI { - return AgentSession.uri(provider.id, generateUuid()); + private async _mintSessionUri(provider: IAgent, config?: IAgentCreateSessionConfig): Promise { + const source = readSessionCreationReference(config?._meta); + const sourceChat = source?.chat ?? (source ? buildDefaultChatUri(source.session) : undefined); + const turnId = source?.turnId ?? (sourceChat ? this._stateManager.getChatState(sourceChat)?.activeTurn?.id : undefined); + const clientId = sourceChat && turnId ? this._turnTracker.getInitiatorClientId(sourceChat, turnId) : config?.activeClient?.clientId; + const legacy = clientId !== undefined && this._clientConnections.usesLegacySessionUris(clientId); + const nativeProvider = provider.id === COPILOT_CLI_AGENT_PROVIDER_ID || provider.id === CODEX_AGENT_PROVIDER_ID || provider.id === CLAUDE_AGENT_PROVIDER_ID; + if (!nativeProvider) { + return AgentSession.uri(provider.id, generateUuid()); + } + const registeredIds = new Set([...await this._sessionRegistry.listRuntimeCompatibleSessionKeys()].map(key => AgentSession.id(key))); + let session: URI; + do { + session = AgentSession.uri(legacy ? provider.id : 'ahp-session', generateUuid()); + } while (registeredIds.has(AgentSession.id(session)) + || await this._sessionRegistry.isTombstoned(session) + || await this._fileService.exists(this._sessionDataService.getSessionDataDir(session))); + return session; } private async _createProviderSession(provider: IAgent, config: IAgentCreateSessionConfig | undefined, deferWorktreeCreation: boolean): Promise { @@ -5348,7 +5452,7 @@ export class AgentService extends Disposable implements IAgentService { this._worktree.notePending(requestedSessionId); } - const session = config?.session ?? this._mintSessionUri(provider); + const session = config?.session ?? await this._mintSessionUri(provider, config); let created: IAgentCreateSessionResult | undefined; try { const providerConfig = config ? this._toProviderConfig(config) : undefined; @@ -5403,18 +5507,6 @@ export class AgentService extends Disposable implements IAgentService { return this._orderSessionChatsForTeardown(session, state?.chats.map(chat => chat.resource) ?? []); } - private async _getSessionChatsForDisposal(provider: IAgent, session: URI): Promise { - const state = this._stateManager.getSessionState(session.toString()); - if (state) { - return this._getSessionChatsInTeardownOrder(session); - } - const persisted = await this._peerChatStore.tryRead(session); - const peerChats = persisted?.map(chat => chat.uri) - ?? (await provider.listLegacyChatBackings?.(session))?.map(chat => chat.uri.toString()) - ?? []; - return this._orderSessionChatsForTeardown(session, peerChats); - } - private _orderSessionChatsForTeardown(session: URI, chats: readonly string[]): URI[] { const defaultChat = buildDefaultChatUri(session.toString()); const result: URI[] = []; @@ -5435,10 +5527,22 @@ export class AgentService extends Disposable implements IAgentService { * Destructively tears a session down: dispose peer chats first and the * default chat last, and still visit every chat if one rejects. */ - private async _disposeSession(provider: IAgent, session: URI): Promise { + private async _disposeSession(provider: IAgent, session: URI, persistedPeers: readonly IPersistedPeerChat[] | undefined): Promise { await this._defaultChatBackingWrites.get(session.toString())?.catch(() => { }); + const state = this._stateManager.getSessionState(session.toString()); + const catalog = persistedPeers ?? (!state ? await provider.listLegacyChatBackings?.(session) : undefined); + const chats = this._orderSessionChatsForTeardown(session, [ + ...(state?.chats.map(chat => chat.resource) ?? []), + ...(catalog?.map(chat => chat.uri.toString()) ?? []), + ]); + for (const chat of chats) { + const persisted = catalog?.find(entry => isEqual(URI.parse(entry.uri.toString()), chat)); + if (!this._stateManager.getChatState(chat.toString()) && (isDefaultChatUri(chat) || persisted)) { + const providerData = isDefaultChatUri(chat) ? await this._readDefaultChatProviderData(session) : persisted?.providerData; + await provider.materializeChat(chat, this._chatContext(session, chat), providerData); + } + } let firstError: unknown; - const chats = await this._getSessionChatsForDisposal(provider, session); for (const chat of chats) { try { await provider.chats.disposeChat(chat, this._chatContext(session, chat)); @@ -5759,6 +5863,10 @@ export class AgentService extends Disposable implements IAgentService { ? withSessionWorkspaceless(_meta, true) : _meta; _meta = withPublishedWorkingDirectoryIdentities(_meta, workingDirectories, undefined); + if (provider.id === CODEX_AGENT_PROVIDER_ID) { + const defaultChat = URI.parse(buildDefaultChatUri(session)); + _meta = withCodexSessionModel(_meta, provider.chats.getModel?.(defaultChat, session) ?? config?.model); + } return { resource: session.toString(), provider: provider.id, @@ -6197,13 +6305,21 @@ export class AgentService extends Disposable implements IAgentService { private async _doDisposeSession(session: URI): Promise { const sessionKey = session.toString(); + this._cancelPendingSessionGc(session); + this._stateManager.invalidateSessionChatResolutions(sessionKey); + const registered = await this._orchestratorDatabase.getSessionV2Registration(sessionKey); + const provider = registered ? this._providerService.getProvider(registered.provider) : this._providerService.getProviderForSession(session); + if (registered && !provider) { + throw new Error(`No agent provider registered for: ${registered.provider}`); + } + if (registered && provider) { + this._providerService.associateSession(session, registered.provider); + } const catalogDeletionFence = this._catalogSyncService.beginSessionDeletion(session); let peerChatDeletionBegun = false; try { - this._cancelPendingSessionGc(session); const isEphemeral = this._stateManager.isEphemeralSession(sessionKey); const isIdleProvisional = this._stateManager.isIdleProvisionalSession(sessionKey); - this._stateManager.invalidateSessionChatResolutions(session.toString()); const sessionChats = this._stateManager.getSessionState(session.toString())?.chats ?? []; for (const chat of sessionChats) { this._sideEffects.clearChannelTelemetry(chat.resource); @@ -6254,7 +6370,6 @@ export class AgentService extends Disposable implements IAgentService { : undefined; await this._peerChatStore.beginSessionDeletion(session); peerChatDeletionBegun = true; - const provider = this._providerService.getProviderForSession(session); let chatsToDelete = this._orderSessionChatsForTeardown(session, [ ...sessionChats.map(chat => chat.resource), ...(persistedPeerChats?.map(chat => chat.uri) ?? []), @@ -6263,7 +6378,7 @@ export class AgentService extends Disposable implements IAgentService { await this._whenBackgroundCatalogStateWritesIdle(sessionKey); await catalogDeletionFence.whenDrained; if (provider) { - chatsToDelete = [...await this._disposeSession(provider, session)]; + chatsToDelete = [...await this._disposeSession(provider, session, persistedPeerChats)]; } if (!isEphemeral) { await this._retryRegistryMutation( @@ -7065,7 +7180,7 @@ export class AgentService extends Disposable implements IAgentService { return action.type === ActionType.AutomationRunCancelRequested; } - dispatchAction(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction | ClientAutomationAction | ClientAutomationRunAction, clientId: string, clientSeq: number, clientContextOrType: IAgentHostClientTelemetryContext | AgentHostClientType = AgentHostClientType.Unknown): void { + dispatchAction(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction | ClientAutomationAction | ClientAutomationRunAction, clientId: string, clientSeq: number, clientContextOrType: IAgentHostClientTelemetryContext | AgentHostClientType = AgentHostClientType.Unknown): void | Promise { const clientContext = typeof clientContextOrType === 'string' ? createUnknownAgentHostClientTelemetryContext(clientContextOrType) : clientContextOrType; @@ -7077,12 +7192,11 @@ export class AgentService extends Disposable implements IAgentService { return; } - void this._dispatchAutomationAction(action, clientId).catch(error => { + return this._dispatchAutomationAction(action, clientId).catch(error => { const message = toErrorMessage(error); this._logService.error(`[AgentService] automation action failed: ${message}`); this._stateManager.rejectClientAction(channel, action, origin, message); }); - return; } if (this._isAutomationRunAction(action)) { const origin = { clientId, clientSeq }; @@ -7090,12 +7204,11 @@ export class AgentService extends Disposable implements IAgentService { this._stateManager.rejectClientAction(channel, action, origin, 'Automation run actions require an automation-run channel.'); return; } - void this._automationService.handleCancel(channel, action).catch(error => { + return this._automationService.handleCancel(channel, action).catch(error => { const message = toErrorMessage(error); this._logService.error(`[AgentService] automation run action failed: ${message}`); this._stateManager.rejectClientAction(channel, action, origin, message); }); - return; } // Clients dispatch chat (chat) actions against a chat channel @@ -7191,6 +7304,7 @@ export class AgentService extends Disposable implements IAgentService { }); this._clientDispatchQueues.set(clientId, next); + return next; } private async _dispatchAutomationAction(action: ClientAutomationAction, clientId: string): Promise { @@ -7271,6 +7385,88 @@ export class AgentService extends Disposable implements IAgentService { }; } + runWithChatCatalogLock(session: URI, operation: () => Promise): Promise { + return this._chatCatalogMutationSequencer.queue(session.toString(), operation); + } + + async setChatWorkingDirectory(session: URI, chat: URI, directory: URI, replaceSessionWorkspace = false, expectedSessionDirectories?: readonly string[]): Promise { + const isCurrentChat = this._stateManager.captureChatValidity(chat.toString()); + const previousSessionDirectories = expectedSessionDirectories ?? this._stateManager.getSessionSummary(session.toString())?.workingDirectories; + const gitState = await this._gitService.getSessionGitState(directory); + const update = () => { + if (replaceSessionWorkspace && !equals(this._stateManager.getSessionSummary(session.toString())?.workingDirectories ?? [], previousSessionDirectories ?? [])) { + throw new Error('The session changed while preparing its replacement workspace.'); + } + return this._setChatWorkingDirectory(session, chat, directory, replaceSessionWorkspace, gitState, isCurrentChat); + }; + if (replaceSessionWorkspace) { + // Exclusive conversion already holds the catalog lock across provider mutation. + await update(); + } else { + await this.runWithChatCatalogLock(session, update); + } + } + + private async _setChatWorkingDirectory(session: URI, chat: URI, directory: URI, replaceSessionWorkspace: boolean, gitState: ISessionGitState | undefined, isCurrentChat: () => boolean): Promise { + const assertCurrentChat = () => { + if (!isCurrentChat()) { + throw new Error(localize('agentHost.chatWorkspaceTargetChanged', "The chat was removed or replaced while changing its workspace.")); + } + }; + assertCurrentChat(); + const directories = [directory.toString()]; + const state = this._stateManager.getSessionState(session.toString()); + const summary = state?.chats.find(candidate => candidate.resource === chat.toString()); + const sessionDirectories = this._stateManager.getSessionSummary(session.toString())?.workingDirectories; + if (!state || !summary || (replaceSessionWorkspace + ? !hasSingleUserChat(state.chats) || !isDefaultChatUri(chat) || !sessionDirectories?.length + : !sessionDirectories?.includes(directories[0]))) { + throw new Error(`Cannot assign an unattached working directory to chat ${chat.toString()}.`); + } + if (isDefaultChatUri(chat)) { + await persistSessionMetadataValues(this._sessionDataService, chat.toString(), { + [CHAT_WORKING_DIRECTORIES_METADATA_KEY]: JSON.stringify(directories), + }); + } else { + if (!equals(summary.workingDirectories ?? sessionDirectories, directories)) { + // Forked chats can inherit a baseline from their previous checkout. + await persistSessionMetadataValues(this._sessionDataService, chat.toString(), { [META_DIFF_BASE_BRANCH]: '' }); + } + assertCurrentChat(); + await this._peerChatStore.updateWorkingDirectories(session, chat, directories); + } + assertCurrentChat(); + await this._gitStateService.setFolderGitState(session.toString(), directories, gitState); + assertCurrentChat(); + if (replaceSessionWorkspace) { + if (!hasSingleUserChat(this._stateManager.getSessionState(session.toString())?.chats ?? []) + || !equals(this._stateManager.getSessionSummary(session.toString())?.workingDirectories, sessionDirectories)) { + throw new Error('The session changed while changing its workspace.'); + } + this._stateManager.setSessionMeta(session.toString(), withSessionGitState(this._stateManager.getSessionState(session.toString())?._meta, gitState)); + this._stateManager.dispatchServerAction(session.toString(), { + type: ActionType.SessionWorkingDirectoryReplaced, + directory: sessionDirectories![0], + replacement: directories[0], + }); + for (const previous of sessionDirectories!.slice(1)) { + if (previous !== directories[0]) { + this._stateManager.dispatchServerAction(session.toString(), { type: ActionType.SessionWorkingDirectoryRemoved, directory: previous }); + } + } + } + for (const previous of summary.workingDirectories ?? state.workingDirectories ?? []) { + this._stateManager.dispatchServerAction(chat.toString(), { type: ActionType.ChatWorkingDirectoryRemoved, directory: previous }); + } + this._stateManager.dispatchServerAction(chat.toString(), { type: ActionType.ChatWorkingDirectorySet, directory: directories[0] }); + this._stateManager.dispatchServerAction(session.toString(), { + type: ActionType.SessionChatUpdated, chat: chat.toString(), changes: { workingDirectories: directories }, + }); + await this._persistListVisibleSessionState(session, { + [SESSION_WORKING_DIRECTORIES_KEY]: JSON.stringify(replaceSessionWorkspace ? directories : sessionDirectories), + }); + } + private async _prepareChatWorkingDirectory(session: URI, directory: URI, options: IAddSessionWorkingDirectoryOptions): Promise<{ readonly directory: URI; readonly added: boolean; readonly createdWorktree?: ISessionAdditionalWorktree }> { let createdWorktree: ISessionAdditionalWorktree | undefined; let previousWorkingDirectories: readonly string[] = []; @@ -7532,6 +7728,42 @@ export class AgentService extends Disposable implements IAgentService { return { ...action, config: { ...action.config, [SessionConfigKey.AgentMergeFolders]: folders } }; } + /** Client actions that would end or detach the host-owned turn an in-flight workspace conversion depends on. */ + private _getWorkspaceConversionRejection(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction): string | undefined { + const isConversionChat = (chat: string) => { + const activeTurnId = this._stateManager.getActiveTurnId(chat); + return !!activeTurnId && this._workspaceConversionService.isConversionTurn(chat, activeTurnId); + }; + switch (action.type) { + case ActionType.ChatTurnCancelled: + return this._workspaceConversionService.isConversionTurn(channel, action.turnId) + ? localize('agentHost.cannotCancelWorkspaceChange', "Cannot cancel while the workspace is changing.") + : undefined; + case ActionType.ChatPendingMessageSet: + return action.kind === PendingMessageKind.Steering && isConversionChat(channel) + ? localize('agentHost.cannotSteerWorkspaceChange', "Cannot steer while the workspace is changing.") + : undefined; + case ActionType.ChatTurnStarted: + return isConversionChat(channel) + ? localize('agentHost.cannotStartTurnWorkspaceChange', "Cannot send a message while the workspace is changing.") + : undefined; + case ActionType.ChatTruncated: + return isConversionChat(channel) + ? localize('agentHost.cannotTruncateWorkspaceChange', "Cannot clear history while the workspace is changing.") + : undefined; + case ActionType.ChatIsArchivedChanged: + return action.isArchived && isConversionChat(channel) + ? localize('agentHost.cannotArchiveChatWorkspaceChange', "Cannot archive a chat while its workspace is changing.") + : undefined; + case ActionType.SessionIsArchivedChanged: + return action.isArchived && this._stateManager.getSessionState(channel)?.chats.some(chat => isConversionChat(chat.resource)) + ? localize('agentHost.cannotArchiveSessionWorkspaceChange', "Cannot archive a session while a workspace is changing.") + : undefined; + default: + return undefined; + } + } + private _dispatchActionNow(channel: string, sessionChannel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction, clientId: string, clientSeq: number, clientContext: IAgentHostClientTelemetryContext): void { const origin = { clientId, clientSeq }; const chatState = action.type === ActionType.ChatIsArchivedChanged || action.type === ActionType.ChatIsReadChanged @@ -7550,6 +7782,11 @@ export class AgentService extends Disposable implements IAgentService { this._stateManager.rejectClientAction(channel, action, origin, 'Cannot unarchive a session while it is being deleted.'); return; } + const conversionRejection = this._getWorkspaceConversionRejection(channel, action); + if (conversionRejection) { + this._stateManager.rejectClientAction(channel, action, origin, conversionRejection); + return; + } if (action.type === ActionType.ChatTurnCancelled) { // Match the turn before reduction so stale no-ops cannot trigger a chat-wide abort. if (this._stateManager.getChatState(channel)?.activeTurn?.id !== action.turnId) { @@ -7568,6 +7805,10 @@ export class AgentService extends Disposable implements IAgentService { this._stateManager.rejectClientAction(channel, action, origin, 'Turn resume requires a chat channel.'); return; } + if (this._workspaceConversionService.isPending(channel)) { + this._stateManager.rejectClientAction(channel, action, origin, 'Cannot resume while workspace setup is pending or blocked.'); + return; + } const chatState = this._stateManager.getChatState(channel); const sessionState = this._stateManager.getSessionState(sessionChannel); const sessionArchived = ((sessionState?.status ?? 0) & SessionStatus.IsArchived) === SessionStatus.IsArchived; @@ -8357,6 +8598,7 @@ export class AgentService extends Disposable implements IAgentService { let gitMetadata: Record | undefined; let changesetMetadata: Record | undefined; let sessionMetadata: Record | undefined; + let persistedWorkingDirectories: string | undefined; let workspaceTransitionsPromise: Promise | undefined> = Promise.resolve(undefined); const ref = this._sessionDataService.tryOpenDatabase?.(session); if (ref) { @@ -8367,6 +8609,7 @@ export class AgentService extends Disposable implements IAgentService { try { const m = await db.object.getMetadataObject({ customTitle: true, + [SESSION_WORKING_DIRECTORIES_KEY]: true, [AH_META_IS_READ_DB_KEY]: true, [AH_META_DEFAULT_CHAT_IS_READ_DB_KEY]: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, @@ -8393,6 +8636,7 @@ export class AgentService extends Disposable implements IAgentService { if (m.customTitle) { title = m.customTitle; } + persistedWorkingDirectories = m[SESSION_WORKING_DIRECTORIES_KEY]; if (m[AH_META_IS_READ_DB_KEY] !== undefined) { isRead = m[AH_META_IS_READ_DB_KEY] === 'true'; } @@ -8552,8 +8796,11 @@ export class AgentService extends Disposable implements IAgentService { ...(chat.changes !== undefined ? { changes: chat.changes } : {}), })); const restoredDefaultChat = cachedChatCatalog?.find(chat => chat.kind === 'default')?.uri; - const workingDirectories = withChatWorkingDirectories(meta.workingDirectories?.map(d => d.toString()), centralChatCatalog); + const workingDirectories = withChatWorkingDirectories(parseSessionWorkingDirectories(persistedWorkingDirectories) ?? meta.workingDirectories?.map(d => d.toString()), centralChatCatalog); restoredMeta = withPublishedWorkingDirectoryIdentities(restoredMeta, workingDirectories, centralChatCatalog); + if (agent.id === CODEX_AGENT_PROVIDER_ID) { + restoredMeta = withCodexSessionModel(restoredMeta, agent.chats.getModel?.(defaultChatUri, this._chatContext(session, defaultChatUri)) ?? meta.model); + } const currentRegistration = await this._sessionRegistry.get(session, entry => this._migrateRegisteredSession(entry)); const effectiveRegistrationSource = currentRegistration?.source ?? registrationSource; const sessionStartTime = currentRegistration?.startTime ?? meta.startTime; @@ -9187,12 +9434,18 @@ export class AgentService extends Disposable implements IAgentService { const backingSessionStr = backingSession.toString(); this._unpersistedChatBackings.add(backingSessionStr); const write = async (): Promise => { + const provider = AgentSession.provider(backingSession); + if (!provider) { + throw new Error(`Chat backing has no provider: ${backingSessionStr}`); + } const ref = this._sessionDataService.openDatabase(backingSession); try { await ref.object.setMetadata(CHAT_BACKING_METADATA_KEY, chat.toString()); + await ref.object.setMetadata(`${CHAT_BACKING_METADATA_KEY}Provider`, provider); } finally { ref.dispose(); } + await this._sessionRegistry.markSessionsV2Excluded({ provider, session: backingSessionStr, reason: 'backing', fingerprint: 'backing-v1' }); }; try { await write(); @@ -9269,7 +9522,7 @@ export class AgentService extends Disposable implements IAgentService { const chat = URI.parse(buildDefaultChatUri(session)); try { const metadata = await agent.getChatMetadata(chat, this._chatContext(session, chat), await this._readDefaultChatProviderData(session), { activation: 'restore' }); - return await this._withWorktreeProject(session, metadata ? this._toSessionMetadata(metadata) : undefined); + return await this._withWorktreeProject(session, metadata ? this._toSessionMetadata(metadata, agent.id) : undefined); } catch (err) { if (err instanceof ProtocolError) { throw err; @@ -9811,11 +10064,14 @@ export class AgentService extends Disposable implements IAgentService { await this._providerService.shutdown(); } } finally { - await this.whenCatalogReconciliationIdle(); - await this._replayPendingPassiveSessionMetadata(); - await this._debugLogsCollector?.cleanup(); - await this._orchestratorDatabase.close(); - this._downloadProgressInterest.clear(); + try { + await this.whenCatalogReconciliationIdle(); + } finally { + await this._replayPendingPassiveSessionMetadata(); + await this._debugLogsCollector?.cleanup(); + await this._orchestratorDatabase.close(); + this._downloadProgressInterest.clear(); + } } } diff --git a/src/vs/platform/agentHost/node/agentServiceComposition.ts b/src/vs/platform/agentHost/node/agentServiceComposition.ts index ae40129277bdf0..a904e951dc18ff 100644 --- a/src/vs/platform/agentHost/node/agentServiceComposition.ts +++ b/src/vs/platform/agentHost/node/agentServiceComposition.ts @@ -6,6 +6,7 @@ import type { Event } from '../../../base/common/event.js'; import { DisposableStore, type IDisposable, MutableDisposable } from '../../../base/common/lifecycle.js'; import type { IObservable } from '../../../base/common/observable.js'; +import { localize } from '../../../nls.js'; import { IInstantiationService, ServicesAccessor } from '../../instantiation/common/instantiation.js'; import { ServiceCollection } from '../../instantiation/common/serviceCollection.js'; import { ILogService } from '../../log/common/log.js'; @@ -162,7 +163,7 @@ export function createAgentServiceComposition( if (!workspaceConversionService.value) { throw new Error('Session workspace conversion is unavailable.'); } - workspaceConversionService.value.requestSessionWorkspaceUpdate(chat, turnId, workspaceFolder, isolation, initiatingClientId); + return workspaceConversionService.value.requestSessionWorkspaceUpdate(chat, turnId, workspaceFolder, isolation, initiatingClientId); }, }; const serverToolHost = new AgentServerToolHost( @@ -172,10 +173,24 @@ export function createAgentServiceComposition( agentMergeTools, callbackAdapter.artifactServerToolAccessor, () => configurationService.getRootValue(platformRootSchema, AgentHostAgentOrchestrationLimitsConfigKey) !== 'off', + { + supportsChatIsolation: session => workspaceConversionService.value?.supportsChatIsolation(session) === true, + requestChatIsolation: (chat, turnId) => { + const initiatingClientId = turnTracker.getInitiatorClientId(chat.toString(), turnId); + if (!initiatingClientId || !workspaceConversionService.value) { + throw new Error(localize('agentHost.chatIsolationClientRequired', "Moving a chat to a worktree with this tool requires a turn initiated by a connected client.")); + } + workspaceConversionService.value.requestChatIsolation(chat, turnId, initiatingClientId); + }, + }, ), ); services.set(IAgentHostServerToolService, serverToolHost); - workspaceConversionService.value = owned.add(instantiationService.createInstance(SessionWorkspaceConversionService)); + workspaceConversionService.value = owned.add(instantiationService.createInstance(SessionWorkspaceConversionService, { + runWithChatCatalogLock: (session, operation) => agentService!.runWithChatCatalogLock(session, operation), + prepareChatWorkingDirectory: callbackAdapter.sessionServerToolAccessor.prepareChatWorkingDirectory, + setChatWorkingDirectory: (session, chat, directory, replaceSessionWorkspace, expectedSessionDirectories) => agentService!.setChatWorkingDirectory(session, chat, directory, replaceSessionWorkspace, expectedSessionDirectories), + })); services.set(ISessionWorkspaceConversionService, workspaceConversionService.value); const collaborators: IAgentServiceCollaborators = { diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index a65542ef7b6afd..dd5132c79182d7 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { getTelemetryChatSessionId } from '../common/agentTelemetryCorrelation.js'; import { readUsageInfoMeta } from '../common/meta/agentUsageMeta.js'; import { getErrorCode, getErrorMessage } from '../../../base/common/errors.js'; import { RunOnceScheduler } from '../../../base/common/async.js'; @@ -24,8 +25,9 @@ import { IAgentHostCheckpointService } from '../common/agentHostCheckpointServic import { IAgentHostChatContributions, type ISendTurnMessageOptions } from '../common/agentHostChatContributionsService.js'; import { AgentHostClientType } from '../common/agentHostClientInfo.js'; import { isRenameChatTool } from '../common/serverToolNames.js'; -import { AgentHostLaunchKind, createUnknownAgentHostClientTelemetryContext, type IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; -import { AgentSession, AgentSignal, IAgent, IAgentChatContext, IAgentToolPendingConfirmationSignal, type AgentSubagentKind, type AgentSubagentTaskModelSource, type IAgentModelCallCompletedSignal, type IAgentModelCallFinishedSignal, type IAgentSubagentStartedSignal } from '../common/agent.js'; +import { type CodexModelProvider, AgentHostLaunchKind, createUnknownAgentHostClientTelemetryContext, type IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; +import { AgentSession, AgentSignal, CODEX_AGENT_PROVIDER_ID, IAgent, IAgentChatContext, IAgentToolPendingConfirmationSignal, type AgentSubagentKind, type AgentSubagentTaskModelSource, type IAgentModelCallCompletedSignal, type IAgentModelCallFinishedSignal, type IAgentSubagentStartedSignal } from '../common/agent.js'; +import { readCodexSessionModel, withCodexSessionModel } from '../common/meta/codexSessionModel.js'; import { isPresentationOnlyToolCall, readToolCallMeta, toToolCallMeta } from '../common/meta/agentToolCallMeta.js'; import { isAgentMergeMessage } from '../common/meta/agentMergeMessageMeta.js'; import { readAgentPermissionResponseMeta } from '../common/meta/agentPermissionResponseMeta.js'; @@ -2069,6 +2071,15 @@ export class AgentSideEffects extends Disposable { })); await Promise.all(selectionUpdates); + if (agent.id === CODEX_AGENT_PROVIDER_ID) { + const state = this._stateManager.getSessionState(sessionChannel); + if (state?.defaultChat === chat) { + const model = agent.chats.getModel?.(chatUri, clientOperationContext) ?? message.model; + if (model && readCodexSessionModel(state)?.id !== model.id) { + this._stateManager.setSessionMeta(sessionChannel, withCodexSessionModel(state._meta, model)); + } + } + } // A provider can prepare the turn — e.g. materialize a deferred session // with the selection applied above — while attachments, contributions @@ -2094,6 +2105,8 @@ export class AgentSideEffects extends Disposable { const contribution = await this._chatContributions.outgoingTurn({ session: sessionChannel, chat, message, turnId, workingDirectories: resolvedWorkingDirectories }); const sendContext = { ...clientOperationContext, + turnTelemetryCorrelation: { agentSessionId: AgentSession.id(sessionChannel), chatSessionId: getTelemetryChatSessionId(turnChannel), turnId }, + reportCodexModelProvider: (provider: CodexModelProvider) => this._turnTracker.setCodexModelProvider(turnChannel, turnId, provider), ...(turnTelemetryContext ? { turnTelemetryContext } : {}), ...(contribution.instructions?.length ? { hostInstructions: contribution.instructions } : {}), sendStageRecorder: this._turnTracker.createProviderStageRecorder(turnChannel, turnId), diff --git a/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionContribution.ts b/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionContribution.ts index d270f465c24261..4e4881e3d80b37 100644 --- a/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionContribution.ts +++ b/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionContribution.ts @@ -6,7 +6,7 @@ import { withMessageRequestHiddenFromTranscript } from '../../../common/meta/agentMessageMeta.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; import { localize } from '../../../../../nls.js'; -import type { IAgentHostChatContribution, IAgentHostChatContributionContext, IHydrationContext, IIncomingRequest, IncomingRequestDisposition, ITurnEnd } from '../../../common/agentHostChatContributionsService.js'; +import type { IAgentHostChatContribution, IAgentHostChatContributionContext, IHydrationContext, IIncomingRequest, IncomingRequestDisposition, IRestoredChat, ITurnEnd } from '../../../common/agentHostChatContributionsService.js'; import { parseAgentWorkspaceTransition, AgentSystemNotificationKind, readAgentSystemNotificationMeta, toAgentSystemNotificationMeta } from '../../../common/meta/agentSystemNotificationMeta.js'; import { toAgentWorkspaceContinuationMessageMeta } from '../../../common/meta/agentWorkspaceContinuationMeta.js'; import { ResponsePartKind, type Turn } from '../../../common/state/sessionState.js'; @@ -30,6 +30,9 @@ export class SessionWorkspaceConversionContribution extends Disposable implement void this._conversionService.updateSessionWorkspace(turn.channel, turn.turnId); } else { this._conversionService.cancel(turn.channel, turn.turnId); + if (turn.reason.kind !== 'rejected' && this._conversionService.isPending(turn.channel)) { + void this._conversionService.updateSessionWorkspace(turn.channel, turn.turnId); + } } } @@ -47,6 +50,11 @@ export class SessionWorkspaceConversionContribution extends Disposable implement }; } + async onHydrateChat(context: IHydrationContext, restored: IRestoredChat): Promise { + await this._conversionService.restoreChatIsolation(context.chat); + return restored; + } + onHydrateTurns(context: IHydrationContext, turns: readonly Turn[]): readonly Turn[] { const transitions = context.workspaceTransitions; if (turns.length === 0 || !transitions?.size) { diff --git a/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.ts b/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.ts index 37abb0b3457555..b5ba4e01e2f77b 100644 --- a/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.ts +++ b/src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.ts @@ -3,24 +3,26 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { raceTimeout } from '../../../../../base/common/async.js'; import { withMessageRequestHiddenFromTranscript, withMessageSystemInitiatedLabel } from '../../../common/meta/agentMessageMeta.js'; import { toErrorMessage } from '../../../../../base/common/errorMessage.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../base/common/network.js'; import { equals } from '../../../../../base/common/objects.js'; -import { basename, isEqual } from '../../../../../base/common/resources.js'; +import { basename, extUriBiasedIgnorePathCase, isEqual } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { createDecorator } from '../../../../instantiation/common/instantiation.js'; import { ILogService } from '../../../../log/common/log.js'; import { AgentSession, AgentWorkingDirectoryChangedError, type IAgent, type IAgentSessionProjectInfo } from '../../../common/agent.js'; +import { IAgentHostGitStateService } from '../../../common/agentHostGitStateService.js'; import { AgentHostGlobalAutoApproveEnabledConfigKey, platformRootSchema, platformSessionSchema } from '../../../common/agentHostSchema.js'; import { toAgentWorkspaceContinuationMessageMeta } from '../../../common/meta/agentWorkspaceContinuationMeta.js'; import { ISessionDataService } from '../../../common/sessionDataService.js'; import { SessionConfigKey } from '../../../common/sessionConfigKeys.js'; import { AgentSystemNotificationKind, AgentSystemNotificationWorkspaceKind, serializeAgentWorkspaceTransition, type IAgentSystemNotificationMeta, type IAgentWorkspaceTransitionRecord, toAgentSystemNotificationMeta } from '../../../common/meta/agentSystemNotificationMeta.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, buildDefaultChatUri, chatStorageUri, isDefaultChatUri, MessageKind, parseChatUri, readSessionWorkspaceless, ResponsePartKind, SessionStatus, withSessionHasWorkspaceTransitions, withSessionWorkspaceless, type ISessionWithDefaultChat, type SessionConfigState, type URI as ProtocolURI } 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, buildDefaultChatUri, ChatInteractivity, ChatOriginKind, chatStorageUri, isDefaultChatUri, isSubagentSession, MessageKind, parseChatUri, parseSubagentSessionUri, readSessionExternal, readSessionWorkspaceless, ResponsePartKind, SessionLifecycle, SessionStatus, withSessionHasWorkspaceTransitions, withSessionWorkspaceless, type ISessionWithDefaultChat, type SessionConfigState, type URI as ProtocolURI } from '../../../common/state/sessionState.js'; import { AgentHostStateManager, IAgentHostStateManager } from '../../agentHostStateManager.js'; import { IAgentHostClientConnectionService } from '../../agentHostClientConnectionService.js'; import { IAgentConfigurationService } from '../../agentConfigurationService.js'; @@ -28,15 +30,30 @@ import { IAgentHostProviderService } from '../../agentHostProviderService.js'; import { IAgentHostTurnService, type IDeferredAgentHostTurn } from '../../agentHostTurnService.js'; import { IAgentHostServerToolService } from '../../shared/agentServerToolHost.js'; import { IAgentHostWorktreeIsolation, type IIsolationConfigContribution } from '../../shared/worktreeIsolation.js'; +import type { IAgentServiceSessionServerToolAccessor } from '../../shared/sessionServerTools.js'; + +const CHAT_ISOLATION_DIRECTORY_KEY = 'agentHost.chatIsolationDirectory'; +const CHAT_ISOLATION_QUARANTINED_KEY = 'agentHost.chatIsolationQuarantined'; + +export interface IChatIsolationHost { + readonly prepareChatWorkingDirectory: IAgentServiceSessionServerToolAccessor['prepareChatWorkingDirectory']; + setChatWorkingDirectory(session: URI, chat: URI, directory: URI, replaceSessionWorkspace: boolean, expectedSessionDirectories?: readonly string[]): Promise; + runWithChatCatalogLock(session: URI, operation: () => Promise): Promise; +} interface IPendingSessionWorkspaceConversion { readonly chat: URI; readonly turnId: string; + readonly chatOnly?: boolean; + readonly replaceSessionWorkspace?: boolean; readonly workspaceFolder: URI; + readonly previousWorkingDirectory?: URI; readonly isolation: boolean; readonly initiatingClientId: string; readonly prompt: string | undefined; readonly showTransition: boolean; + /** The host-owned continuation turn of a deferred conversion, once it has started. */ + continuationTurnId?: string; phase: 'requested' | 'converting'; resolvedWorkingDirectory?: URI; transition?: IAgentWorkspaceTransitionRecord; @@ -49,6 +66,15 @@ interface IResolvedWorkspace { readonly isolationConfig: IIsolationConfigContribution | undefined; readonly isolated: boolean; readonly project: IAgentSessionProjectInfo | undefined; + readonly branchName?: string; +} + +/** Upper bound for restoring sibling chats before a multi-chat workspace change; the chat cannot be cancelled while it waits. */ +const SIBLING_RESTORE_TIMEOUT_MS = 30_000; + +/** Whether the session has exactly one chat the user works in; transient subagent chats never count as siblings. */ +export function hasSingleUserChat(chats: readonly { readonly origin?: { readonly kind: ChatOriginKind } }[]): boolean { + return chats.filter(chat => chat.origin?.kind !== ChatOriginKind.Tool).length === 1; } class UnsafeProviderWorkingDirectoryError extends Error { @@ -59,21 +85,33 @@ export const ISessionWorkspaceConversionService = createDecorator; + /** Returns false when the chat already uses the requested folder without needing a workspace change. */ + requestSessionWorkspaceUpdate(chat: URI, turnId: string, workspaceFolder: URI, isolation: boolean, initiatingClientId: string): boolean; + isPending(chat: ProtocolURI, sessionWide?: boolean): boolean; + /** Whether the turn is host-owned and needed to finish an in-flight conversion, so a client must not cancel it. */ + isConversionTurn(chat: ProtocolURI, turnId: string): boolean; cancel(chat: ProtocolURI, turnId: string | undefined): void; updateSessionWorkspace(chat: ProtocolURI, turnId: string | undefined): Promise; } -/** Converts workspace-less sessions in place while preserving their session and chat identities. */ +/** Changes session workspaces in place while preserving session and chat identities. */ export class SessionWorkspaceConversionService extends Disposable implements ISessionWorkspaceConversionService { declare readonly _serviceBrand: undefined; private readonly _pending = new Map(); private readonly _quarantined = new Set(); + private readonly _quarantinedChats = new Set(); + private readonly _restoringChats = new Map(); + private readonly _isolatedChats = new Set(); constructor( + private readonly _chatIsolationHost: IChatIsolationHost | undefined, @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager, @IAgentHostProviderService private readonly _providerService: IAgentHostProviderService, @ISessionDataService private readonly _sessionDataService: ISessionDataService, @@ -83,20 +121,156 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe @IAgentHostTurnService private readonly _turnService: IAgentHostTurnService, @IAgentHostServerToolService private readonly _serverToolHost: IAgentHostServerToolService, @ILogService private readonly _logService: ILogService, + @IAgentHostGitStateService private readonly _gitStateService: IAgentHostGitStateService, ) { super(); + this._register(this._stateManager.onDidEmitEnvelope(envelope => { + if (envelope.action.type === ActionType.SessionChatRemoved) { + this._clearChatIsolationState(envelope.action.chat); + } + if (envelope.action.type === ActionType.SessionReady) { + const session = parseChatUri(envelope.channel)?.session ?? envelope.channel; + if (this._stateManager.getSessionState(session)?.chats.some(chat => this.canIsolateChat(URI.parse(chat.resource)))) { + this._serverToolHost.advertise(session); + } + } + })); this._register(this._stateManager.onDidRemoveSession(session => { const chat = buildDefaultChatUri(session); - this._pending.delete(chat); - this._quarantined.delete(chat); + this._clearChatIsolationState(chat); + for (const key of new Set([...this._pending.keys(), ...this._quarantinedChats, ...this._isolatedChats, ...this._restoringChats.keys()])) { + if (parseChatUri(key)?.session === session) { + this._clearChatIsolationState(key); + } + } })); } - requestSessionWorkspaceUpdate(chat: URI, turnId: string, workspaceFolder: URI, isolation: boolean, initiatingClientId: string): void { + private _clearChatIsolationState(chat: ProtocolURI): void { + this._pending.delete(chat); + this._quarantined.delete(chat); + this._quarantinedChats.delete(chat); + this._restoringChats.delete(chat); + this._isolatedChats.delete(chat); + } + + supportsChatIsolation(session: URI): boolean { + const provider = this._providerService.getProviderForSession(session); + return !!this._chatIsolationHost && !isSubagentSession(session.toString()) + && this._worktreeIsolation.supported && provider?.agentHostCapabilities.workspaceConversion === true + && !!provider.setChatWorkingDirectory; + } + + /** + * Whether the chat can move into a new worktree. A chat already in a worktree can still move to a worktree of + * a different {@link workspaceFolder}, but a chat with its own isolated worktree only when it replaces the session workspace. + */ + canIsolateChat(chat: URI, workspaceFolder?: URI): boolean { + const parsed = parseChatUri(chat); + const state = parsed && this._stateManager.getSessionState(parsed.session); + const directories = state?.chats.find(candidate => candidate.resource === chat.toString())?.workingDirectories + ?? (parsed ? this._stateManager.getSessionSummary(parsed.session)?.workingDirectories : undefined); + const movesElsewhere = !!workspaceFolder && !!directories?.length && !isEqual(workspaceFolder, URI.parse(directories[0])); + return this._canChangeChatWorkspace(chat) && !!state && this._worktreeIsolation.supported + && !readSessionWorkspaceless(state._meta) + && !(!movesElsewhere && state.config?.values[SessionConfigKey.Isolation] === 'worktree' + && directories?.[0] === this._stateManager.getSessionSummary(parsed!.session)?.workingDirectories?.[0]) + && (!this._isolatedChats.has(chat.toString()) || (movesElsewhere && hasSingleUserChat(state.chats))); + } + + private _canChangeChatWorkspace(chat: URI): boolean { + const parsed = parseChatUri(chat); + const state = parsed && this._stateManager.getSessionState(parsed.session); + const summary = state?.chats.find(candidate => candidate.resource === chat.toString()); + const sessionDirectories = parsed ? this._stateManager.getSessionSummary(parsed.session)?.workingDirectories : undefined; + const directories = summary?.workingDirectories ?? sessionDirectories; + const provider = parsed && this._providerService.getProviderForSession(URI.parse(parsed.session)); + return !!parsed && !!this._chatIsolationHost && !isSubagentSession(parsed.session) + && provider?.agentHostCapabilities.workspaceConversion === true && !!provider.setChatWorkingDirectory && !!summary + && state?.lifecycle === SessionLifecycle.Ready + && summary.origin?.kind !== ChatOriginKind.Tool + && (summary.interactivity === undefined || summary.interactivity === ChatInteractivity.Full) + && ((summary.status ?? 0) & SessionStatus.IsArchived) === 0 + && !readSessionExternal(state._meta) + && (state.status & SessionStatus.IsArchived) !== SessionStatus.IsArchived + && directories?.length === 1 && URI.parse(directories[0]).scheme === Schemas.file + && (hasSingleUserChat(state.chats) || !!provider?.getDescriptor().capabilities?.multipleWorkingDirectories) + && (!readSessionWorkspaceless(state._meta) || hasSingleUserChat(state.chats)) + && !this.isPending(chat.toString()); + } + + requestChatIsolation(chat: URI, turnId: string, initiatingClientId: string): void { + if (!initiatingClientId || this._stateManager.getActiveTurnId(chat.toString()) !== turnId) { + throw new Error(localize('agentHost.chatIsolationRequiresTurn', "Changing a chat's workspace with this tool requires an active turn initiated by a connected client.")); + } + if (!this.canIsolateChat(chat)) { + throw new Error(localize('agentHost.chatIsolationRequirements', "This chat's workspace cannot be changed to a new worktree. It must work in one local folder and must not already use a worktree or be changing workspace.")); + } + const state = this._stateManager.getSessionState(parseChatUri(chat)!.session)!; + const summary = state.chats.find(candidate => candidate.resource === chat.toString())!; + this._pending.set(chat.toString(), { + chat, turnId, chatOnly: true, + replaceSessionWorkspace: hasSingleUserChat(state.chats), + workspaceFolder: URI.parse((summary.workingDirectories ?? this._stateManager.getSessionSummary(parseChatUri(chat)!.session)?.workingDirectories)![0]), + isolation: true, initiatingClientId, + prompt: this._stateManager.getChatState(chat.toString())?.activeTurn?.message.text, + showTransition: true, phase: 'requested', + }); + } + + async restoreChatIsolation(chat: ProtocolURI): Promise { + const storage = chatStorageUri(chat); + if (!storage) { + return; + } + const restore = Symbol(); + this._restoringChats.set(chat, restore); + this._quarantinedChats.add(chat); + try { + const database = await this._sessionDataService.tryOpenDatabase(storage); + try { + const directory = await database?.object.getMetadata(CHAT_ISOLATION_DIRECTORY_KEY); + const quarantined = await database?.object.getMetadata(CHAT_ISOLATION_QUARANTINED_KEY); + if (this._restoringChats.get(chat) === restore) { + if (directory) { + this._isolatedChats.add(chat); + } else { + this._isolatedChats.delete(chat); + } + if (quarantined !== 'true') { + this._quarantinedChats.delete(chat); + } + } + } finally { + database?.dispose(); + } + } finally { + if (this._restoringChats.get(chat) === restore) { + this._restoringChats.delete(chat); + } + } + } + + requestSessionWorkspaceUpdate(chat: URI, turnId: string, workspaceFolder: URI, isolation: boolean, initiatingClientId: string): boolean { if (!initiatingClientId) { throw new Error('Session workspace conversion requires an initiating client.'); } - this._validateConversion(chat, workspaceFolder); + const session = parseChatUri(chat)?.session; + const state = session ? this._stateManager.getSessionState(session) : undefined; + const chatOnly = !!state && !readSessionWorkspaceless(state._meta); + if (chatOnly) { + if (!this._canChangeChatWorkspace(chat)) { + throw new Error(localize('agentHost.chatWorkspaceUnavailable', "This chat's workspace cannot be changed.")); + } + if (isolation && !this.canIsolateChat(chat, workspaceFolder)) { + throw new Error(localize('agentHost.chatWorkspaceIsolationUnavailable', "This chat's workspace cannot be changed to a new worktree of {0}. The chat already uses a worktree of it, or worktrees are not available for it.", workspaceFolder.fsPath)); + } + if (workspaceFolder.scheme !== Schemas.file || !workspaceFolder.path.startsWith('/') || workspaceFolder.query || workspaceFolder.fragment) { + throw new Error(localize('agentHost.chatWorkspaceInvalid', "Select an existing folder on the chat's host.")); + } + } else { + this._validateConversion(chat, workspaceFolder); + } const activeTurnId = this._stateManager.getActiveTurnId(chat.toString()); if (activeTurnId !== turnId) { throw new Error('Session workspace conversion must be requested from the active turn.'); @@ -107,11 +281,43 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe 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, showTransition: !!chatState?.turns.length, phase: 'requested' }); + const directories = state?.chats.find(candidate => candidate.resource === key)?.workingDirectories ?? state?.workingDirectories; + if (chatOnly && !isolation && extUriBiasedIgnorePathCase.isEqual(workspaceFolder, URI.parse(directories![0]))) { + return false; + } + this._pending.set(key, { + chat, turnId, workspaceFolder, isolation, initiatingClientId, prompt, + showTransition: chatOnly || !!chatState?.turns.length, phase: 'requested', + ...(chatOnly ? { + chatOnly: true, replaceSessionWorkspace: hasSingleUserChat(state.chats), + previousWorkingDirectory: URI.parse(directories![0]), + } : {}), + }); + return true; } - isPending(chat: ProtocolURI): boolean { - return this._pending.has(chat) || this._quarantined.has(chat); + isPending(chat: ProtocolURI, sessionWide = false): boolean { + const key = this._pendingKey(chat); + const pending = this._pending.get(key); + return (!!pending && (!pending.chatOnly || pending.replaceSessionWorkspace || (!sessionWide && pending.chat.toString() === chat))) + || this._quarantined.has(key) || (!sessionWide && this._quarantinedChats.has(chat)); + } + + isConversionTurn(chat: ProtocolURI, turnId: string): boolean { + const pending = this._pending.get(chat); + return pending?.phase === 'converting' && pending.continuationTurnId === turnId; + } + + private _pendingKey(chat: ProtocolURI): string { + if (this._pending.get(chat)?.chatOnly) { + return chat; + } + let session = parseChatUri(chat)?.session ?? chat; + let parent; + while ((parent = parseSubagentSessionUri(session))) { + session = parent.parentSession.toString(); + } + return buildDefaultChatUri(session); } cancel(chat: ProtocolURI, turnId: string | undefined): void { @@ -122,8 +328,9 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe } async updateSessionWorkspace(chat: ProtocolURI, turnId: string | undefined): Promise { - const pending = this._pending.get(chat); - if (!pending || pending.turnId !== turnId || pending.phase !== 'requested') { + const key = this._pendingKey(chat); + const pending = this._pending.get(key); + if (!pending || pending.phase === 'converting' || pending.turnId !== turnId || pending.chat.toString() !== chat) { return; } @@ -131,11 +338,16 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe let continuation: IDeferredAgentHostTurn | undefined; try { continuation = this._beginContinuation(pending); + pending.continuationTurnId = continuation.turnId; if (pending.showTransition) { pending.transition = this._createWorkspaceTransition(pending); } - pending.resolvedWorkingDirectory = await this._convert(pending, continuation); - this._pending.delete(chat); + pending.resolvedWorkingDirectory = pending.chatOnly + ? pending.replaceSessionWorkspace + ? await this._chatIsolationHost!.runWithChatCatalogLock(URI.parse(parseChatUri(pending.chat)!.session), () => this._convertChat(pending, continuation!)) + : await this._convertChat(pending, continuation) + : await this._convert(pending, continuation); + this._pending.delete(key); await this._continueConversion(continuation, pending, true); } catch (error) { let conversionError = error; @@ -148,16 +360,215 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe } this._logService.error(`[SessionWorkspaceConversionService] Failed to convert ${pending.chat.toString()}: ${toErrorMessage(conversionError)}`); if (conversionError instanceof UnsafeProviderWorkingDirectoryError) { - this._pending.delete(chat); - this._quarantined.add(chat); + if (this._pending.get(key) === pending) { + this._pending.delete(key); + (pending.chatOnly ? this._quarantinedChats : this._quarantined).add(key); + } this._failConversion(continuation, pending, conversionError); } else { - this._pending.delete(chat); + if (this._pending.get(key) === pending) { + this._pending.delete(key); + } await this._continueConversion(continuation, pending, false, conversionError); } } } + private async _convertChat(pending: IPendingSessionWorkspaceConversion, continuation: IDeferredAgentHostTurn): Promise { + const host = this._chatIsolationHost!; + const { chat, workspaceFolder, initiatingClientId } = pending; + const session = URI.parse(parseChatUri(chat)!.session); + const storage = chatStorageUri(chat)!; + const provider = this._providerService.getProviderForSession(session)!; + let workspaceFinalized = false; + const assertWorkspace = (expected = pending.previousWorkingDirectory ?? workspaceFolder) => { + const state = this._stateManager.getSessionState(session.toString()); + const summary = state?.chats.find(candidate => candidate.resource === chat.toString()); + const directories = summary?.workingDirectories ?? this._stateManager.getSessionSummary(session.toString())?.workingDirectories; + if (!summary || state?.lifecycle !== SessionLifecycle.Ready + || (state.status & SessionStatus.IsArchived) === SessionStatus.IsArchived + || ((summary.status ?? 0) & SessionStatus.IsArchived) !== 0 + || summary.origin?.kind === ChatOriginKind.Tool + || (summary.interactivity !== undefined && summary.interactivity !== ChatInteractivity.Full) + || (pending.isolation && readSessionWorkspaceless(state._meta)) || readSessionExternal(state._meta) + || (pending.isolation && !this._worktreeIsolation.supported) || !provider?.agentHostCapabilities.workspaceConversion || !provider.setChatWorkingDirectory + || this._providerService.getProviderForSession(session) !== provider + || this._pending.get(chat.toString()) !== pending || this._quarantinedChats.has(chat.toString()) + || (!workspaceFinalized && pending.isolation && state.config?.values[SessionConfigKey.Isolation] === 'worktree' + && isEqual(workspaceFolder, pending.previousWorkingDirectory) + && directories?.[0] === this._stateManager.getSessionSummary(session.toString())?.workingDirectories?.[0]) + || (pending.replaceSessionWorkspace ? !hasSingleUserChat(state.chats) : !provider.getDescriptor().capabilities?.multipleWorkingDirectories) + || directories?.length !== 1 || !isEqual(URI.parse(directories[0]), expected) + || this._stateManager.getActiveTurnId(chat.toString()) !== continuation.turnId) { + throw new Error(localize('agentHost.chatWorkspaceChanged', "The chat changed while preparing its new workspace.")); + } + }; + const trustRequired = this._configurationService.getRootValue(platformRootSchema, AgentHostGlobalAutoApproveEnabledConfigKey) !== true + && (this._configurationService.getEffectiveValue(session.toString(), platformSessionSchema, SessionConfigKey.AutoApprove) ?? 'default') !== 'autoApprove'; + assertWorkspace(); + await this._requireWorkspaceTrust(trustRequired, initiatingClientId, workspaceFolder); + assertWorkspace(); + if (!pending.replaceSessionWorkspace) { + // A provider can only keep a sibling in its current folder once it has restored that chat. Subagent chats never hold a folder of their own. + const siblings = (this._stateManager.getSessionState(session.toString())?.chats ?? []) + .filter(sibling => sibling.resource !== chat.toString() && sibling.origin?.kind !== ChatOriginKind.Tool); + let restored: Awaited>[] | undefined; + try { + restored = await raceTimeout(Promise.all(siblings.map(sibling => this._stateManager.resolveChatState(sibling.resource))), SIBLING_RESTORE_TIMEOUT_MS); + } catch (error) { + throw new Error(localize('agentHost.chatWorkspaceSiblingRestoreFailed', "The workspace cannot be changed because another chat in this session could not be restored: {0}", toErrorMessage(error))); + } + if (!restored || restored.some(state => !state)) { + throw new Error(localize('agentHost.chatWorkspaceSiblingUnavailable', "The workspace cannot be changed because another chat in this session could not be restored.")); + } + assertWorkspace(); + } + const database = this._sessionDataService.openDatabase(storage); + let providerChanged = false; + let prepared: Awaited> | undefined; + let workspace: IResolvedWorkspace | undefined; + let workspaceMetadata: Readonly> | undefined; + try { + if (pending.replaceSessionWorkspace) { + const sessionDirectory = this._stateManager.getSessionSummary(session.toString())?.workingDirectories?.[0]; + if (pending.isolation && pending.previousWorkingDirectory && sessionDirectory && isEqual(pending.previousWorkingDirectory, URI.parse(sessionDirectory)) + && this._stateManager.getSessionState(session.toString())?.config?.values[SessionConfigKey.Isolation] === 'worktree') { + // The session's current worktree is kept on disk; the new worktree takes over the session's worktree ownership. + await this._worktreeIsolation.retainSessionWorktree(session, AgentSession.id(session)); + assertWorkspace(); + } + workspace = await this._resolveWorkspace(session, chat, workspaceFolder, pending.isolation, trustRequired, initiatingClientId, pending.prompt, this._stateManager.getSessionState(session.toString())?.config?.values); + if (!pending.isolation) { + const externalWorktree = await this._worktreeIsolation.resolveExternalWorktreeProject(workspaceFolder); + workspace = { ...workspace, project: externalWorktree?.project ?? { uri: workspaceFolder, displayName: basename(workspaceFolder) } }; + workspaceMetadata = externalWorktree?.metadata; + } + prepared = { + directory: workspace.workingDirectory, + release: async () => { + if (!pending.isolation) { + return; + } + const error = await this._removeWorktree(session); + if (error) { + throw error; + } + }, + }; + } else { + prepared = await host.prepareChatWorkingDirectory(session, workspaceFolder, { isolation: pending.isolation ? 'worktree' : 'folder', forceNewWorktree: pending.isolation, prompt: pending.prompt ?? '' }); + if (pending.isolation) { + await this._requireWorkspaceTrust(trustRequired, initiatingClientId, prepared.directory, workspaceFolder); + } + } + assertWorkspace(); + await database.object.setMetadata(CHAT_ISOLATION_QUARANTINED_KEY, 'true'); + assertWorkspace(); + const sessionDirectoriesBeforeProviderMove = this._stateManager.getSessionSummary(session.toString())?.workingDirectories; + const context = { configurationResource: session, resource: storage }; + if (pending.replaceSessionWorkspace) { + await provider.setChatWorkingDirectory!(chat, context, prepared.directory, { replaceSessionWorkspace: true }); + } else { + await provider.setChatWorkingDirectory!(chat, context, prepared.directory); + } + providerChanged = true; + assertWorkspace(); + if (!pending.isolation && pending.replaceSessionWorkspace) { + await this._worktreeIsolation.retainSessionWorktree(session, AgentSession.id(session)); + assertWorkspace(); + } + if (workspace?.branchName) { + this._stateManager.setSessionMeta(session.toString(), this._gitStateService.getMaterializedWorktreeMeta(session.toString(), workspace.branchName)); + } + await host.setChatWorkingDirectory(session, chat, prepared.directory, pending.replaceSessionWorkspace === true, sessionDirectoriesBeforeProviderMove); + assertWorkspace(prepared.directory); + let configValues: Record | undefined; + while (true) { + const currentValues = this._stateManager.getSessionState(session.toString())?.config?.values; + if (workspace) { + configValues = { ...currentValues, [SessionConfigKey.Isolation]: workspace.configValues[SessionConfigKey.Isolation] }; + if (pending.isolation) { + configValues[SessionConfigKey.Branch] = workspace.configValues[SessionConfigKey.Branch]; + } else { + delete configValues[SessionConfigKey.Branch]; + } + } + const metadata = { + [CHAT_ISOLATION_DIRECTORY_KEY]: pending.isolation ? prepared.directory.toString() : '', + [CHAT_ISOLATION_QUARANTINED_KEY]: 'false', + ...(configValues ? { configValues: JSON.stringify(configValues) } : {}), + ...workspaceMetadata, + ...(!pending.isolation && pending.replaceSessionWorkspace ? { [AH_META_WORKSPACELESS_DB_KEY]: 'false' } : {}), + }; + if (pending.transition) { + await database.object.setWorkspaceConversion(continuation.turnId, serializeAgentWorkspaceTransition(pending.transition), metadata); + pending.transitionPersisted = true; + } else { + await database.object.setMetadataValues(metadata); + } + assertWorkspace(prepared.directory); + if (!workspace || equals(currentValues, this._stateManager.getSessionState(session.toString())?.config?.values)) { + break; + } + } + workspaceFinalized = true; + if (workspace) { + if (workspace.project) { + this._stateManager.setSessionProject(session.toString(), { uri: workspace.project.uri.toString(), displayName: workspace.project.displayName }); + } + this._updateIsolationConfig(session, this._stateManager.getSessionState(session.toString())?.config, configValues!, workspace.isolationConfig, pending.isolation); + if (!pending.isolation) { + this._stateManager.setSessionMeta(session.toString(), withSessionWorkspaceless(this._stateManager.getSessionState(session.toString())?._meta, false)); + } + } + if (pending.isolation) { + this._isolatedChats.add(chat.toString()); + } else { + this._isolatedChats.delete(chat.toString()); + } + if (isDefaultChatUri(chat)) { + this._stateManager.setSessionMeta(session.toString(), withSessionHasWorkspaceTransitions(this._stateManager.getSessionState(session.toString())?._meta, true)); + } + this._serverToolHost.advertise(session.toString()); + if (pending.replaceSessionWorkspace) { + try { + const customizations = await provider.getChatCustomizations(chat, session); + if (this._pending.get(chat.toString()) === pending) { + this._stateManager.dispatchServerAction(session.toString(), { type: ActionType.SessionCustomizationsChanged, customizations: [...customizations] }); + } + } catch (error) { + this._logService.error(`[SessionWorkspaceConversionService] Failed to refresh customizations for ${session.toString()}: ${toErrorMessage(error)}`); + } + } + assertWorkspace(prepared.directory); + return prepared.directory; + } catch (error) { + if (this._pending.get(chat.toString()) !== pending) { + if (!providerChanged) { + await prepared?.release(); + } + throw error; + } + if (providerChanged || error instanceof AgentWorkingDirectoryChangedError) { + try { + await database.object.setMetadata(CHAT_ISOLATION_QUARANTINED_KEY, 'true'); + } catch (persistenceError) { + throw new UnsafeProviderWorkingDirectoryError(localize('agentHost.chatWorkspaceFinalizeAndQuarantineFailed', "The workspace change could not be finalized: {0}; failed to persist quarantine: {1}", toErrorMessage(error), toErrorMessage(persistenceError))); + } + throw new UnsafeProviderWorkingDirectoryError(localize('agentHost.chatWorkspaceFinalizeFailed', "The workspace change could not be finalized: {0}", toErrorMessage(error))); + } + try { + await database.object.deleteMetadata([CHAT_ISOLATION_QUARANTINED_KEY]); + } catch (persistenceError) { + throw new UnsafeProviderWorkingDirectoryError(localize('agentHost.chatWorkspaceAndQuarantineFailed', "Changing the chat's workspace failed: {0}; failed to clear quarantine: {1}", toErrorMessage(error), toErrorMessage(persistenceError))); + } + await prepared?.release(); + throw error; + } finally { + database.dispose(); + } + } + private async _convert(pending: IPendingSessionWorkspaceConversion, continuation: IDeferredAgentHostTurn): Promise { const { chat, workspaceFolder, isolation, initiatingClientId, prompt, transition } = pending; const { session, state, previousWorkingDirectory } = this._validateConversion(chat, workspaceFolder); @@ -286,16 +697,19 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe displayName: project.displayName, }); } + const workspaceMeta = worktreeApplied && resolvedWorkspace.branchName + ? this._gitStateService.getMaterializedWorktreeMeta(session.toString(), resolvedWorkspace.branchName) + : finalState._meta; this._stateManager.setSessionMeta( session.toString(), - withSessionHasWorkspaceTransitions(withSessionWorkspaceless(finalState._meta, false), persistTransition), + withSessionHasWorkspaceTransitions(withSessionWorkspaceless(workspaceMeta, false), persistTransition), ); this._stateManager.dispatchServerAction(session.toString(), { type: ActionType.SessionWorkingDirectoryReplaced, directory: previousWorkingDirectory, replacement: authoritativeWorkingDirectory.toString(), }); - this._updateIsolationConfig(session, finalState.config, configPatch, resolvedWorkspace.isolationConfig, worktreeApplied); + this._updateIsolationConfig(session, finalState.config, configValues ?? configPatch, resolvedWorkspace.isolationConfig, worktreeApplied); this._serverToolHost.advertise(session.toString()); try { const customizations = await provider.getChatCustomizations(chat, session); @@ -374,9 +788,11 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe currentConfig: Record | undefined, ): Promise { if (!isolation) { + const configValues: Record = { ...currentConfig, [SessionConfigKey.Isolation]: 'folder' }; + delete configValues[SessionConfigKey.Branch]; return { workingDirectory: workspaceFolder, - configValues: { ...currentConfig, [SessionConfigKey.Isolation]: 'folder' }, + configValues, isolationConfig: undefined, isolated: false, project: undefined, @@ -423,14 +839,16 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe ? `The isolated worktree project could not be resolved, and cleanup failed: ${toErrorMessage(cleanupError)}` : 'The isolated worktree project could not be resolved.'); } - return { workingDirectory, configValues, isolationConfig, isolated: true, project: worktreeInfo.project }; + return { workingDirectory, configValues, isolationConfig, isolated: true, project: worktreeInfo.project, branchName: worktreeInfo.branchName }; } private async _removeWorktree(session: URI): Promise { const sessionId = AgentSession.id(session); try { const worktree = await this._worktreeIsolation.prepareSessionDeletion(session, sessionId); - await this._worktreeIsolation.discardSessionWorktree(session, sessionId, worktree); + await this._worktreeIsolation.discardSessionWorktree(session, sessionId, worktree, { + preserveWorkingDirectory: !readSessionWorkspaceless(this._stateManager.getSessionState(session.toString())?._meta), + }); return undefined; } catch (error) { return error; @@ -464,6 +882,7 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe this._stateManager.dispatchServerAction(session.toString(), { type: ActionType.SessionConfigChanged, config: configPatch, + replace: true, }); } } @@ -514,17 +933,23 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe private _beginContinuation(pending: IPendingSessionWorkspaceConversion): IDeferredAgentHostTurn { const continuation = this._turnService.beginDeferredTurnMessage(pending.chat, withMessageRequestHiddenFromTranscript(withMessageSystemInitiatedLabel({ - text: localize('agentHost.continueInWorkspaceMessage', "Continue in the requested workspace."), + text: pending.chatOnly && pending.isolation + ? localize('agentHost.continueIsolatedMessage', "Continue the task in the worktree.") + : localize('agentHost.continueInWorkspaceMessage', "Continue in the requested workspace."), origin: { kind: MessageKind.SystemNotification }, _meta: toAgentWorkspaceContinuationMessageMeta(), - }, localize('agentHost.continueInWorkspaceLabel', "Continue in Requested Workspace")), true)); + }, pending.chatOnly && pending.isolation + ? localize('agentHost.continueIsolatedLabel', "Continue in Worktree") + : localize('agentHost.continueInWorkspaceLabel', "Continue in Requested Workspace")), true)); return continuation; } private _createWorkspaceTransition(pending: IPendingSessionWorkspaceConversion): IAgentWorkspaceTransitionRecord { const workspaceName = basename(pending.workspaceFolder) || pending.workspaceFolder.path; return { - content: localize('agentHost.workspaceTransitionLabel', "Now working in {0}", workspaceName), + content: pending.isolation + ? localize('agentHost.chatIsolationTransitionLabel', "Workspace changed to a new worktree of {0}", workspaceName) + : localize('agentHost.workspaceTransitionLabel', "Workspace changed to {0}", workspaceName), workspaceKind: pending.isolation ? AgentSystemNotificationWorkspaceKind.Worktree : AgentSystemNotificationWorkspaceKind.Folder, workspaceName, }; @@ -537,7 +962,11 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe } const errorMessage = error === undefined ? undefined : toErrorMessage(error).replace(/\.+$/, ''); const text = converted - ? `The current session is now attached to ${(pending.resolvedWorkingDirectory ?? pending.workspaceFolder).fsPath}${pending.isolation ? ' in an isolated worktree' : ''}. Continue the user's original task in this workspace. Do not request another session or workspace conversion.` + ? pending.chatOnly + ? pending.isolation + ? `Only this chat now uses the worktree at ${pending.resolvedWorkingDirectory!.fsPath}. Other chats and their folders are unchanged. The session workspace includes this worktree. Continue the user's original task here without requesting another worktree.` + : `Only this chat now uses the workspace at ${pending.resolvedWorkingDirectory!.fsPath}. Other chats and their folders are unchanged. Continue the user's original task here. Do not change this workspace again unless the user explicitly asks.` + : `The current session is now attached to ${(pending.resolvedWorkingDirectory ?? pending.workspaceFolder).fsPath}${pending.isolation ? ' in an isolated worktree' : ''}. Continue the user's original task in this workspace. Do not request another session or workspace conversion.` : `The requested workspace setup did not complete successfully: ${errorMessage}. Do not run the user's task. Tell the user that workspace setup failed and include this error.`; const label = converted ? localize('agentHost.workspaceSetLabel', "Workspace Set") @@ -594,7 +1023,7 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe await database.object.deleteTurnWorkspaceTransition(turnId); const hasTransitions = await database.object.getMetadata(AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY) === 'true'; const session = parseChatUri(chat)?.session; - if (session) { + if (session && isDefaultChatUri(chat)) { const state = this._stateManager.getSessionState(session); this._stateManager.setSessionMeta(session, withSessionHasWorkspaceTransitions(state?._meta, hasTransitions)); } @@ -637,6 +1066,9 @@ export class SessionWorkspaceConversionService extends Disposable implements ISe override dispose(): void { this._pending.clear(); this._quarantined.clear(); + this._quarantinedChats.clear(); + this._restoringChats.clear(); + this._isolatedChats.clear(); super.dispose(); } } diff --git a/src/vs/platform/agentHost/node/claude/claudeFileEditObserver.ts b/src/vs/platform/agentHost/node/claude/claudeFileEditObserver.ts index 001f1423b61456..abdf13d0419655 100644 --- a/src/vs/platform/agentHost/node/claude/claudeFileEditObserver.ts +++ b/src/vs/platform/agentHost/node/claude/claudeFileEditObserver.ts @@ -11,6 +11,7 @@ import { ISessionDatabase } from '../../common/sessionDataService.js'; import type { IAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; import { isSubagentChatUri } from '../../common/state/sessionState.js'; import { FileEditTracker } from '../shared/fileEditTracker.js'; +import { CLAUDE_AGENT_PROVIDER_ID } from '../../common/agent.js'; import type { ClaudeMapperState } from './claudeMapSessionEvents.js'; import { getClaudeToolPath, isClaudeFileEditTool } from './claudeToolDisplay.js'; @@ -75,6 +76,7 @@ export class ClaudeFileEditObserver extends Disposable { FileEditTracker, sessionUri, dbRef.object, + CLAUDE_AGENT_PROVIDER_ID, ); } diff --git a/src/vs/platform/agentHost/node/codex/codexAccountTelemetry.ts b/src/vs/platform/agentHost/node/codex/codexAccountTelemetry.ts index 158f5c64f01125..605f77e8634587 100644 --- a/src/vs/platform/agentHost/node/codex/codexAccountTelemetry.ts +++ b/src/vs/platform/agentHost/node/codex/codexAccountTelemetry.ts @@ -6,53 +6,35 @@ import type { ICodexAccountTelemetryContext } from '../../common/agentHostTelemetry.js'; import type { ICodexAccountRateLimitInfo } from '../../common/codexAccount.js'; import type { ICodexAccountState } from './codexAccountState.js'; -import type { PlanType } from './protocol/generated/PlanType.js'; +import { normalizeChatGPTPlanTier } from '../../common/codexAccountPlan.js'; +export { normalizeChatGPTPlanTier } from '../../common/codexAccountPlan.js'; const MAX_RATE_LIMIT_SNAPSHOT_AGE_MS = 5 * 60 * 1000; const WEEKLY_RATE_LIMIT_WINDOW_MINS = 7 * 24 * 60; - -const chatgptPlanTiers = { - free: 'free', - go: 'go', - plus: 'plus', - pro: 'pro', - prolite: 'pro', - team: 'business', - self_serve_business_prolite: 'business', - self_serve_business_usage_based: 'business', - business: 'business', - ent26: 'enterprise', - enterprise_cbp_automation: 'enterprise', - enterprise_cbp_usage_based: 'enterprise', - enterprise: 'enterprise', - edu: 'edu', - edu_plus: 'edu', - edu_pro: 'edu', - unknown: 'unknown', -} as const satisfies Record>; +const FIVE_HOUR_RATE_LIMIT_WINDOW_MINS = 5 * 60; export type CodexAccountTelemetryClassification = { chatgptAccountState?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'ChatGPT sign-in availability at Codex turn start: signedIn, signedOut, or unknown. Describes account context independently of the turn model provider.' }; chatgptPlanTier?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Public ChatGPT plan family at Codex turn start for signed-in accounts: free, go, plus, pro, business, enterprise, edu, or unknown. Used to understand feature use across supported account configurations.' }; chatgptWeeklyQuotaState?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'State of the cached ChatGPT weekly limit at Codex turn start: available, unavailable, missing, nonWeekly, stale, expired, or invalid. Distinguishes usable and unavailable turn context.' }; chatgptWeeklyUsedPercentBucket?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Cached ChatGPT weekly used percentage at Codex turn start, rounded down to a 10-point bucket (0 through 100). Only for a signed-in account and a valid seven-day window observed within five minutes and before any known reset, to contextualize turn behavior.' }; + chatgptFiveHourQuotaState?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'State of the cached ChatGPT five-hour limit at Codex turn start: available, unavailable, missing, stale, expired, or invalid. Distinguishes usable and unavailable turn context.' }; + chatgptFiveHourUsedPercentBucket?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Cached ChatGPT five-hour used percentage at Codex turn start, rounded down to a 10-point bucket (0 through 100). Only for a signed-in account and a valid five-hour window observed within five minutes and before any known reset, to contextualize turn behavior.' }; }; -export function normalizeChatGPTPlanTier(planType: unknown): NonNullable { - return typeof planType === 'string' && Object.hasOwn(chatgptPlanTiers, planType) ? chatgptPlanTiers[planType as PlanType] : 'unknown'; -} - -export function getCodexAccountTelemetryContext(account: ICodexAccountState | undefined, rateLimit: ICodexAccountRateLimitInfo | undefined, observedAt: number | undefined, now = Date.now()): ICodexAccountTelemetryContext { +export function getCodexAccountTelemetryContext(account: ICodexAccountState | undefined, rateLimit: ICodexAccountRateLimitInfo | undefined, observedAt: number | undefined, now = Date.now(), rateLimits?: readonly ICodexAccountRateLimitInfo[]): ICodexAccountTelemetryContext { if (account?.status !== 'signedIn' || account.authType !== 'chatgpt') { return Object.freeze({ chatgptAccountState: account?.status === 'signedOut' ? 'signedOut' : 'unknown', chatgptWeeklyQuotaState: 'unavailable', + chatgptFiveHourQuotaState: 'unavailable', }); } return Object.freeze({ chatgptAccountState: 'signedIn', chatgptPlanTier: normalizeChatGPTPlanTier(account.planType), ...getWeeklyQuotaTelemetry(rateLimit, observedAt, now), + ...getFiveHourQuotaTelemetry(rateLimits, observedAt, now), }); } @@ -63,6 +45,8 @@ export function getCodexAccountTelemetryData(context: ICodexAccountTelemetryCont ...(context.chatgptPlanTier !== undefined ? { chatgptPlanTier: context.chatgptPlanTier } : {}), chatgptWeeklyQuotaState: context.chatgptWeeklyQuotaState, ...(context.chatgptWeeklyUsedPercentBucket !== undefined ? { chatgptWeeklyUsedPercentBucket: context.chatgptWeeklyUsedPercentBucket } : {}), + chatgptFiveHourQuotaState: context.chatgptFiveHourQuotaState, + ...(context.chatgptFiveHourUsedPercentBucket !== undefined ? { chatgptFiveHourUsedPercentBucket: context.chatgptFiveHourUsedPercentBucket } : {}), } : undefined; } @@ -87,3 +71,22 @@ function getWeeklyQuotaTelemetry(rateLimit: ICodexAccountRateLimitInfo | undefin } return { chatgptWeeklyQuotaState: 'available', chatgptWeeklyUsedPercentBucket: Math.floor(rateLimit.usedPercent / 10) * 10 }; } + +function getFiveHourQuotaTelemetry(rateLimits: readonly ICodexAccountRateLimitInfo[] | undefined, observedAt: number | undefined, now: number): Pick { + const rateLimit = rateLimits?.find(candidate => candidate.windowDurationMins === FIVE_HOUR_RATE_LIMIT_WINDOW_MINS); + if (!rateLimit || observedAt === undefined) { + return { chatgptFiveHourQuotaState: 'missing' }; + } + if (!Number.isFinite(observedAt) || observedAt < 0 || observedAt > now + || !Number.isFinite(rateLimit.usedPercent) || rateLimit.usedPercent < 0 || rateLimit.usedPercent > 100 + || (rateLimit.resetsAt !== undefined && (!Number.isFinite(rateLimit.resetsAt) || rateLimit.resetsAt <= 0))) { + return { chatgptFiveHourQuotaState: 'invalid' }; + } + if (now - observedAt > MAX_RATE_LIMIT_SNAPSHOT_AGE_MS) { + return { chatgptFiveHourQuotaState: 'stale' }; + } + if (rateLimit.resetsAt !== undefined && rateLimit.resetsAt * 1000 <= now) { + return { chatgptFiveHourQuotaState: 'expired' }; + } + return { chatgptFiveHourQuotaState: 'available', chatgptFiveHourUsedPercentBucket: Math.floor(rateLimit.usedPercent / 10) * 10 }; +} diff --git a/src/vs/platform/agentHost/node/codex/codexAgent.ts b/src/vs/platform/agentHost/node/codex/codexAgent.ts index d5f9338e2a4079..6f72398b0e5bbd 100644 --- a/src/vs/platform/agentHost/node/codex/codexAgent.ts +++ b/src/vs/platform/agentHost/node/codex/codexAgent.ts @@ -31,10 +31,11 @@ import { createSchema, platformRootSchema, platformSessionSchema, schemaProperty import { createPricingMetaFromBilling, normalizeCAPIBilling, type ICAPIModelBilling } from '../../common/meta/agentModelMeta.js'; import { ContextSizeConfigKey, createContextSizeConfigSchemaProperty, createContextSizeConfigSchemaPropertyFromLimits, getModelContextSize } from '../../common/agentModelConfiguration.js'; import { CHATGPT_SUBSCRIPTION_MODEL_SOURCE_ID, createAgentModelGroupMeta, createAgentModelSourceMeta } from '../../common/agentModelSource.js'; +import { withCodexSessionModel } from '../../common/meta/codexSessionModel.js'; import { AgentSystemNotificationKind, toAgentSystemNotificationMeta } from '../../common/meta/agentSystemNotificationMeta.js'; import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../../common/agentHostCustomizationConfig.js'; import { AgentSdkSetupChannel } from '../agentSdkSetupChannel.js'; -import { CODEX_ACCOUNT_META_KEY, CODEX_ACCOUNT_SIGN_IN_REQUEST_KEY, CODEX_ACCOUNT_SIGN_OUT_REQUEST_KEY, type ICodexAccountInfo } from '../../common/codexAccount.js'; +import { CODEX_ACCOUNT_META_KEY, CODEX_ACCOUNT_REFRESH_REQUEST_KEY, CODEX_ACCOUNT_SIGN_IN_REQUEST_KEY, CODEX_ACCOUNT_SIGN_OUT_REQUEST_KEY, type ICodexAccountInfo } from '../../common/codexAccount.js'; import { getReasoningEffortDescription, getReasoningEffortLabel, resolveDefaultReasoningEffort } from '../../common/reasoningEffort.js'; import { AgentChatMigrationDeferred, type AgentChatMigrationResult, AgentSession, AgentSignal, AgentWorkingDirectoryChangedError, CODEX_AGENT_PROVIDER_ID, IActiveClient, IAgent, IAgentChatConfigCompletionsParams, IAgentChatContext, IAgentChatDataChange, IAgentChatHistoryChange, IAgentChatMetadata, type IAgentChatMetadataOptions, IAgentChats, IAgentCreateChatForkSource, IAgentCreateChatResult, IAgentCreateChatOptions, IAgentDescriptor, IAgentDiscoveredChat, IAgentMaterializeChatEvent, IAgentModelInfo, type IAgentPrepareChatResult, IAgentResolveChatConfigParams, IAgentSpawnChatEvent, IMcpNotification, resolveAgentChatContext, resolveAgentHostInstructions, type AgentProvider, type AuthenticateParams } from '../../common/agent.js'; import { AgentHostCodexAgentBinaryArgsEnvVar, AgentHostCodexAgentCodexHomeEnvVar, AgentHostCodexAgentSdkRootEnvVar } from '../../common/agentService.js'; @@ -84,7 +85,7 @@ import { IAgentSdkDownloader, IAgentSdkPackage } from '../agentSdkDownloader.js' import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; -import { CodexAppServerClient, JsonRpcError, JsonRpcErrorCode, transportFromChildProcess, type ICodexAppServerClient, type ServerRequestHandlerResult } from './codexAppServerClient.js'; +import { CodexAppServerClient, JsonRpcError, JsonRpcErrorCode, JsonRpcResponseError, transportFromChildProcess, type ICodexAppServerClient, type ServerRequestHandlerResult } from './codexAppServerClient.js'; import { CODEX_PORTABLE_HISTORY_HEADER, ICodexProxyService, type ICodexProxyHandle } from './codexProxyService.js'; import { GITHUB_MCP_SERVER_NAME, resolveGitHubMcpServerConfiguration } from '../shared/githubMcpServer.js'; import { isAgentHostTelemetryService } from '../agentHostTelemetryService.js'; @@ -164,6 +165,8 @@ import type { ThreadApproveGuardianDeniedActionResponse } from './protocol/gener import type { ConfigReadResponse } from './protocol/generated/v2/ConfigReadResponse.js'; import type { ConfigWriteResponse } from './protocol/generated/v2/ConfigWriteResponse.js'; import { ensurePortableCodexProxyProvider } from './codexProviderConfiguration.js'; +import { parseCodexModelSelection, toCodexModelSelectionId, toCodexModelProvider } from '../../common/codexModelSelection.js'; +export { parseCodexModelSelection, toCodexModelSelectionId } from '../../common/codexModelSelection.js'; import { reportCodexProviderSwitch } from './codexProviderSwitchTelemetry.js'; import { formatGuardianDenialNotification, formatGuardianReviewStatusNotification, summarizeGuardianReviewAction, toGuardianAssessmentEventJson } from './codexGuardianReview.js'; import { CODEX_COMPACT_SLASH_COMMAND } from '../codexCompactCommand.js'; @@ -265,7 +268,6 @@ const CODEX_RESPONSES_ENDPOINT = '/responses'; const CODEX_COPILOT_MODEL_PROVIDER = 'vscode-proxy'; const CODEX_COPILOT_MODEL_GROUP = 'copilot'; const CODEX_OPENAI_MODEL_PROVIDER = 'openai'; -const CODEX_MODEL_SELECTION_PREFIX = '@provider='; const CODEX_MODEL_CATALOG_TIMEOUT_MS = 15_000; const CODEX_MODEL_CATALOG_MAX_BUFFER = 8 * 1024 * 1024; @@ -352,28 +354,6 @@ function isCodexCompatibleCopilotModel(model: CCAModel): boolean { && !!model.supported_endpoints?.includes(CODEX_RESPONSES_ENDPOINT); } -export function toCodexModelSelectionId(modelProvider: string, modelId: string): string { - return `${CODEX_MODEL_SELECTION_PREFIX}${encodeURIComponent(modelProvider)}:${encodeURIComponent(modelId)}`; -} - -export function parseCodexModelSelection(selection: ModelSelection): { readonly modelProvider: string; readonly modelId: string } { - if (!selection.id.startsWith(CODEX_MODEL_SELECTION_PREFIX)) { - return { modelProvider: CODEX_COPILOT_MODEL_PROVIDER, modelId: selection.id }; - } - const separator = selection.id.indexOf(':', CODEX_MODEL_SELECTION_PREFIX.length); - if (separator < CODEX_MODEL_SELECTION_PREFIX.length) { - return { modelProvider: CODEX_COPILOT_MODEL_PROVIDER, modelId: selection.id }; - } - try { - return { - modelProvider: decodeURIComponent(selection.id.slice(CODEX_MODEL_SELECTION_PREFIX.length, separator)), - modelId: decodeURIComponent(selection.id.slice(separator + 1)), - }; - } catch { - return { modelProvider: CODEX_COPILOT_MODEL_PROVIDER, modelId: selection.id }; - } -} - /** * Codex's Agent Mode schema, derived from the platform-generic Mode schema but * with "Autopilot" removed. Codex has only two native collaboration modes — @@ -1387,6 +1367,11 @@ export class CodexAgent extends Disposable implements IAgent { void this._reconcileMaterializedCustomizations(session); } } + const refreshRequest = this._configurationService.getRootConfigValues?.()[CODEX_ACCOUNT_REFRESH_REQUEST_KEY]; + if (typeof refreshRequest === 'string') { + this._configurationService.updateRootConfig({ [CODEX_ACCOUNT_REFRESH_REQUEST_KEY]: undefined }); + void this._refreshContinuationAccount(); + } const signInRequest = this._configurationService.getRootConfigValues?.()[CODEX_ACCOUNT_SIGN_IN_REQUEST_KEY]; if (typeof signInRequest === 'string' && signInRequest !== this._lastSignInRequest) { this._lastSignInRequest = signInRequest; @@ -1450,13 +1435,27 @@ export class CodexAgent extends Disposable implements IAgent { } captureTurnTelemetryContext(): IAgentProviderTurnTelemetryContext { - return { codex: getCodexAccountTelemetryContext(this._openAIAccountState, this._openAIAccountRateLimit, this._openAIAccountRateLimitUpdatedAt) }; + return { codex: getCodexAccountTelemetryContext(this._openAIAccountState, this._openAIAccountRateLimit, this._openAIAccountRateLimitUpdatedAt, Date.now(), this._openAIAccountRateLimits) }; } private _publishAccountInfo(account: ICodexAccountInfo): void { this._configurationService.publishRootTransientValues?.({ [CODEX_ACCOUNT_META_KEY]: account }); } + /** Refreshes account limits without enumerating or publishing provider sessions. */ + private async _refreshContinuationAccount(): Promise { + try { + if (!(await this._isSdkResolvableWithoutDownload())) { + return; + } + await this._withOnDemandConnection(async client => { + await this._refreshAccount(client, true, true); + }); + } catch (error) { + this._logService.warn('[Codex] Continuation account refresh failed', error); + } + } + private async _signInToChatGPT(request: string): Promise { const progressInterest = this._agentSdkDownloader.acquireDownloadProgressInterest(CodexSdkPackage); try { @@ -1550,6 +1549,7 @@ export class CodexAgent extends Disposable implements IAgent { requiresOpenaiAuth: state.requiresOpenaiAuth, rateLimit: state.authType === 'chatgpt' ? this._openAIAccountRateLimit : undefined, rateLimits: state.authType === 'chatgpt' ? this._openAIAccountRateLimits : undefined, + observedAt: state.authType === 'chatgpt' ? this._openAIAccountRateLimitUpdatedAt : undefined, }; } @@ -2841,7 +2841,8 @@ export class CodexAgent extends Disposable implements IAgent { * {@link _handleDynamicToolCallRpc} by name. */ private _buildDynamicTools(session: ICodexSession): DynamicToolSpec[] | undefined { - const serverTools = this._serverToolHost?.getDefinitionsForSession(session.configurationResource.toString()) ?? []; + const serverTools = (this._serverToolHost?.getDefinitionsForSession(session.configurationResource.toString()) ?? []) + .filter(tool => !tool.topLevelChatOnly || !!session.chatChannel); const clientTools = session.clientToolSet.merged(); // Server tools first; a server tool name shadows a colliding client tool // (the agent host owns those names) and matches the routing order below. @@ -2884,6 +2885,12 @@ export class CodexAgent extends Disposable implements IAgent { const host = this._serverToolHost; if (host && params.namespace === null && host.toolNames.includes(params.tool)) { try { + const definition = host.definitions.find(tool => tool.name === params.tool); + if (definition?.topLevelChatOnly && (session.threadId !== params.threadId + || this._subagentsByThreadId.has(params.threadId) + || !host.getDefinitionsForSession(session.configurationResource.toString()).some(tool => tool.name === params.tool))) { + return { result: this._toolFailure(`Server tool ${params.tool} is only available to top-level chats`) }; + } const chatChannel = session.chatChannel?.toString(); if (!chatChannel) { return { result: this._toolFailure(`No chat channel for server tool ${params.tool}`) }; @@ -4263,7 +4270,15 @@ export class CodexAgent extends Disposable implements IAgent { } async setWorkingDirectory(chat: URI, context: URI | IAgentChatContext, workingDirectory: URI): Promise { - const session = this._resolveWorkingDirectoryChangeSession(chat, context); + return this._setWorkingDirectory(chat, context, workingDirectory, false); + } + + async setChatWorkingDirectory(chat: URI, context: IAgentChatContext, workingDirectory: URI): Promise { + return this._setWorkingDirectory(chat, context, workingDirectory, true); + } + + private async _setWorkingDirectory(chat: URI, context: URI | IAgentChatContext, workingDirectory: URI, chatOnly: boolean): Promise { + const session = this._resolveWorkingDirectoryChangeSession(chat, context, chatOnly); if (this._workingDirectoryMutations.has(session)) { throw new Error(`Cannot change the working directory for chat '${chat.toString()}' while another working-directory change is active`); } @@ -4273,7 +4288,6 @@ export class CodexAgent extends Disposable implements IAgent { if (isEqual(session.workingDirectory, workingDirectory)) { return; } - const change: ICodexWorkingDirectoryChange = { threadId: session.threadId!, previousWorkingDirectory: session.workingDirectory!, @@ -4288,7 +4302,7 @@ export class CodexAgent extends Disposable implements IAgent { throw new Error(`Cannot change the working directory because '${workingDirectory.fsPath}' is not an existing directory`); } const { threadId, connection } = await this._ensureThreadConnection(session); - if (change.updated.isSettled || this._resolveWorkingDirectoryChangeSession(chat, context) !== session || threadId !== change.threadId) { + if (change.updated.isSettled || this._resolveWorkingDirectoryChangeSession(chat, context, chatOnly) !== session || threadId !== change.threadId) { throw new CancellationError(); } change.requested = true; @@ -4323,7 +4337,7 @@ export class CodexAgent extends Disposable implements IAgent { workingDirectories: session.workingDirectories, managedWorkingDirectory: null, ownsManagedWorkingDirectory: false, - }); + }, chatOnly); await this._refreshSessionMcpDiscovery(session); if (!isEqual(appliedDirectory, workingDirectory)) { throw new Error(`Codex applied '${appliedDirectory.fsPath}' instead of '${workingDirectory.fsPath}'`); @@ -4336,6 +4350,10 @@ export class CodexAgent extends Disposable implements IAgent { if (change.requested && !change.updated.value && !session.disposed) { this._markSessionForReload(session); } + if (chatOnly && change.requested && !(error instanceof JsonRpcResponseError && !change.updated.value)) { + // Only an answered rejection proves Codex stayed in the old directory; timeouts and transport loss leave the outcome unknown. + throw new AgentWorkingDirectoryChangedError(workingDirectory, `The Codex working directory update could not be confirmed: ${error instanceof Error ? error.message : String(error)}`); + } throw error; } finally { this._workingDirectoryMutations.delete(session); @@ -4343,7 +4361,7 @@ export class CodexAgent extends Disposable implements IAgent { } } - private _resolveWorkingDirectoryChangeSession(chat: URI, context: URI | IAgentChatContext): ICodexSession { + private _resolveWorkingDirectoryChangeSession(chat: URI, context: URI | IAgentChatContext, chatOnly = false): ICodexSession { const resolved = resolveAgentChatContext(context, chat); const runtime = this._resolveConversationSession(chat, resolved); const session = runtime ? this._sessions.get(AgentSession.id(runtime)) : undefined; @@ -4354,7 +4372,7 @@ export class CodexAgent extends Disposable implements IAgent { if (session.currentTurnId || session.currentAppTurnId || session.materializePromise || session.resumePromise) { throw new Error(`Cannot change the working directory while Codex chat '${chat.toString()}' is active`); } - if (this._workingDirectories(session).length !== 1 || this._configScopeChats.get(session.configurationResource.toString())?.size !== 1) { + if (this._workingDirectories(session).length !== 1 || (!chatOnly && this._configScopeChats.get(session.configurationResource.toString())?.size !== 1)) { throw new Error(`Cannot change the working directory for a multi-root or shared Codex configuration '${session.configurationResource.toString()}'`); } return session; @@ -4568,7 +4586,8 @@ export class CodexAgent extends Disposable implements IAgent { readonly chats: IAgentChats = { prepareChat: (chat, context) => this._chatLifecycleSequencer.queue(chat.toString(), () => this._prepareChat(chat, context)), createChat: (chat: URI, context: URI | IAgentChatContext, options?: IAgentCreateChatOptions): Promise => { - return this._chatLifecycleSequencer.queue(chat.toString(), () => this._createChat(chat, resolveAgentChatContext(context, chat), options)); + const resolved = resolveAgentChatContext(context, chat); + return this._chatLifecycleSequencer.queue(chat.toString(), () => this._createChat(chat, resolved, options)); }, disposeChat: (chat: URI, context: URI | IAgentChatContext): Promise => this._chatLifecycleSequencer.queue(chat.toString(), () => this._disposeChat(chat, context)), releaseChat: (chat: URI, context: URI | IAgentChatContext): Promise => this._chatLifecycleSequencer.queue(chat.toString(), () => this._releaseChat(chat, context)), @@ -5122,7 +5141,11 @@ export class CodexAgent extends Disposable implements IAgent { // folder on the strength of a (possibly stale) ownership flag alone. const managedWorkingDirectory = this._releasedManagedWorkingDirectories.get(sessionId) ?? overlay.managedWorkingDirectory; const workingDirectory = overlay.cwd ?? managedWorkingDirectory; - const model = await this._resolveRestoredModel(overlay.modelId ? { id: overlay.modelId } : decoded.model); + // Reuse native model metadata already read by discovery. Re-attaching a + // backing must not start the SDK or issue a thread/read just to fill in + // a missing model; the host's metadata/history reads own that I/O. + const savedModel = overlay.modelId ? { id: overlay.modelId } : decoded.model ?? this._codexChatMetadata.get(threadId)?.metadata.model; + const model = await this._resolveRestoredModel(savedModel); this._throwIfShuttingDown(); // Codex's session id == thread id convention: the backing thread already // exists on the app-server, so the entry resumes on first send. @@ -6081,6 +6104,7 @@ export class CodexAgent extends Disposable implements IAgent { const threadId = session.threadId!; const turnOptions = this._turnStartOptions(session, resolvedModel.modelId, currentCustomizationLaunch.developerInstructions, configResource); const modelProvider = session.materializedModelProvider; + operationContext?.reportCodexModelProvider?.(toCodexModelProvider(modelProvider)); const providerSwitch = session.pendingModelProviderSwitch; const hostInstructions = resolveAgentHostInstructions(operationContext); session.lastPromptText = prompt; @@ -6106,7 +6130,7 @@ export class CodexAgent extends Disposable implements IAgent { session.pendingModelProviderSwitch = undefined; } if (providerSwitch?.threadId === threadId) { - reportCodexProviderSwitch(this._telemetryService, providerSwitch.fromProvider, modelProvider, this._desktopThreadIds.has(threadId), accountTelemetryContext); + reportCodexProviderSwitch(this._telemetryService, providerSwitch.fromProvider, modelProvider, this._desktopThreadIds.has(threadId), accountTelemetryContext, operationContext?.turnTelemetryCorrelation); } // We don't await turn completion here — the notification // stream emits ChatTurnComplete asynchronously. @@ -7083,6 +7107,7 @@ export class CodexAgent extends Disposable implements IAgent { this._advertiseServerTools(live, session); const discovered = this._discoveredCodexChats.get(chat.toString()); const nativeMetadata = !live.currentTurnId && discovered && discovered.modifiedTime >= live.modifiedTime ? discovered : undefined; + const meta = withCodexSessionModel(nativeMetadata?._meta, live.model); return { chat, startTime: live.startTime, @@ -7090,12 +7115,17 @@ export class CodexAgent extends Disposable implements IAgent { summary: nativeMetadata?.summary ?? live.summary, workingDirectories: live.workingDirectories ?? (live.workingDirectory ? [live.workingDirectory] : undefined), ...(live.model ? { model: live.model } : {}), + ...(meta ? { _meta: meta } : {}), }; } // Session listing is ambient. The host-owned registry supplies a stable // fallback row that this lazy provider explicitly accepts until the user // opens this Codex session; only then may the app-server be asked for // authoritative thread metadata. + const discovered = this._discoveredCodexChats.get(chat.toString()); + if (discovered && options?.activation !== 'restore') { + return discovered; + } if (!this._activated) { return options?.registryFallback ? { chat, ...options.registryFallback } : undefined; } @@ -7363,7 +7393,7 @@ export class CodexAgent extends Disposable implements IAgent { const metadata = await Promise.all(threads.filter(thread => !thread.parentThreadId && !(typeof thread.source === 'object' && hasKey(thread.source, { subAgent: true }))).map(async thread => { const sessionUri = liveUriByThreadId.get(thread.id) ?? AgentSession.uri(this.id, thread.id); const liveWorkingDirectories = this._sessions.get(AgentSession.id(sessionUri))?.workingDirectories; - const key = JSON.stringify([sessionUri, thread.createdAt, thread.updatedAt, thread.name, thread.preview, thread.cwd, thread.source, thread.path, thread.modelProvider, liveWorkingDirectories]); + const key = JSON.stringify([sessionUri, thread.createdAt, thread.updatedAt, thread.name, thread.preview, thread.cwd, thread.source, thread.path, thread.modelProvider, thread.model, liveWorkingDirectories]); const cached = this._codexChatMetadata.get(thread.id); if (cached?.key === key) { nextMetadata.set(thread.id, cached); @@ -7540,7 +7570,9 @@ export class CodexAgent extends Disposable implements IAgent { if (generatedWorkspace && isDesktop === undefined) { isDesktop = (await this._desktopRolloutPrefixLimiter.queue(() => this._readCodexDesktopRolloutPrefix(thread))) !== null; } - const model = toRolloutModelSelection(rolloutMetadata?.selectedModel); + const model = toRolloutModelSelection(rolloutMetadata?.selectedModel) + ?? (thread.model && thread.modelProvider ? { id: toCodexModelSelectionId(thread.modelProvider, thread.model) } : undefined); + const meta = withCodexSessionModel(generatedWorkspace && isDesktop ? withSessionWorkspaceless(undefined, true) : undefined, model); return { chat, // Codex returns Unix seconds; the agent host expects ms. @@ -7549,7 +7581,7 @@ export class CodexAgent extends Disposable implements IAgent { summary: codexDelegationDisplayText(thread.name) ?? codexDelegationDisplayText(thread.preview), workingDirectories: thread.cwd ? [URI.file(thread.cwd)] : undefined, ...(model ? { model } : {}), - ...(generatedWorkspace && isDesktop ? { _meta: withSessionWorkspaceless(undefined, true) } : {}), + ...(meta ? { _meta: meta } : {}), }; } @@ -8241,6 +8273,7 @@ export class CodexAgent extends Disposable implements IAgent { } if (!session.mcpController) { session.mcpController = this._instantiationService.createInstance(McpCustomizationController, { + provider: this.id, chatUri: session.chatChannel, emit: action => this._emitMcpCustomizationAction(session, action), capabilities: CODEX_MCP_APP_CAPABILITIES, diff --git a/src/vs/platform/agentHost/node/codex/codexAppServerClient.ts b/src/vs/platform/agentHost/node/codex/codexAppServerClient.ts index 2672011199e57c..3035c4135a738e 100644 --- a/src/vs/platform/agentHost/node/codex/codexAppServerClient.ts +++ b/src/vs/platform/agentHost/node/codex/codexAppServerClient.ts @@ -68,6 +68,14 @@ export class JsonRpcError extends Error { } } +/** + * A {@link JsonRpcError} that the Codex server actually returned in a + * response envelope, so the request is known to have been answered and + * rejected. Transport failures (exit, write failure) reject with a plain + * {@link JsonRpcError} instead because the server's outcome is unknown. + */ +export class JsonRpcResponseError extends JsonRpcError { } + // #region Typed method projections // // Extract `` → `params` / `result` for each direction. The @@ -267,7 +275,7 @@ export class CodexAppServerClient extends Disposable implements ICodexAppServerC } this._pending.delete(id); if (hasKey(msg, { error: true }) && msg.error) { - pending.reject(new JsonRpcError(msg.error.code, msg.error.message, msg.error.data)); + pending.reject(new JsonRpcResponseError(msg.error.code, msg.error.message, msg.error.data)); } else { pending.resolve((msg as IWireResponseSuccess).result); } diff --git a/src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts b/src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts index 8b2034bae3d7a6..b0c2a7307ec156 100644 --- a/src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts +++ b/src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts @@ -338,6 +338,10 @@ export function codexImageGenerationLabels(status?: string): { readonly displayN }; } +export function codexImageGenerationToolInput(revisedPrompt: string | null): string | undefined { + return revisedPrompt ? JSON.stringify({ prompt: revisedPrompt }) : undefined; +} + function jsonValueToText(value: JsonValue): string { return typeof value === 'string' ? value : JSON.stringify(value, null, 2); } @@ -739,7 +743,7 @@ function mapItemStartedBody( turnId: params.turnId, toolCallId, invocationMessage: labels.invocationMessage, - toolInput: JSON.stringify({ prompt: params.item.revisedPrompt ?? labels.displayName }), + toolInput: codexImageGenerationToolInput(params.item.revisedPrompt), confirmed: ToolCallConfirmationReason.NotNeeded, }, ]; @@ -1163,7 +1167,19 @@ export function mapItemCompleted( if (params.item.type === 'imageGeneration') { const success = params.item.status === 'completed' && params.item.result.length > 0; const labels = codexImageGenerationLabels(params.item.status); - return [{ + const actions: ChatAction[] = []; + const toolInput = codexImageGenerationToolInput(params.item.revisedPrompt); + if (toolInput !== undefined) { + actions.push({ + type: ActionType.ChatToolCallReady, + turnId: entry.turnId, + toolCallId: entry.toolCallId, + invocationMessage: labels.invocationMessage, + toolInput, + confirmed: ToolCallConfirmationReason.NotNeeded, + }); + } + actions.push({ type: ActionType.ChatToolCallComplete, turnId: entry.turnId, toolCallId: entry.toolCallId, @@ -1177,7 +1193,8 @@ export function mapItemCompleted( }] : undefined, ...(success ? {} : { error: { message: labels.errorMessage } }), }, - }]; + }); + return actions; } if (params.item.type === 'fileChange') { const output = fileChangeOutput(params.item.changes) || entry.output; diff --git a/src/vs/platform/agentHost/node/codex/codexProviderSwitchTelemetry.ts b/src/vs/platform/agentHost/node/codex/codexProviderSwitchTelemetry.ts index 26c444c7bd4d76..bd5e6c597e150e 100644 --- a/src/vs/platform/agentHost/node/codex/codexProviderSwitchTelemetry.ts +++ b/src/vs/platform/agentHost/node/codex/codexProviderSwitchTelemetry.ts @@ -4,12 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import type { ITelemetryService } from '../../../telemetry/common/telemetry.js'; -import type { ICodexAccountTelemetryContext } from '../../common/agentHostTelemetry.js'; +import type { ICodexAccountTelemetryContext, IAgentTurnTelemetryCorrelation } from '../../common/agentHostTelemetry.js'; import { getCodexAccountTelemetryData, type CodexAccountTelemetryClassification } from './codexAccountTelemetry.js'; type CodexSubscriptionProvider = 'openai' | 'copilot'; -type CodexProviderSwitchEvent = Partial & { +type CodexProviderSwitchEvent = Partial & Partial & { fromProvider: CodexSubscriptionProvider; toProvider: CodexSubscriptionProvider; isDesktopThread: boolean; @@ -19,11 +19,14 @@ type CodexProviderSwitchClassification = CodexAccountTelemetryClassification & { fromProvider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The previous configured subscription provider: openai or copilot.' }; toProvider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The subscription provider used by the accepted turn: openai or copilot.' }; isDesktopThread: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether existing native metadata identifies the thread as originating in Codex Desktop, not which client last used it.' }; + agentSessionId?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The agent host session identifier.' }; + chatSessionId?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The telemetry-safe chat identifier, matching turn telemetry.' }; + turnId?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The accepted turn identifier.' }; owner: 'Giuspepe'; comment: 'Counts accepted Codex turns following an OpenAI/Copilot provider switch observed by Agent Host. Does not observe switches performed exclusively in other clients.'; }; -export function reportCodexProviderSwitch(telemetryService: ITelemetryService, fromModelProvider: string | undefined, toModelProvider: string | undefined, isDesktopThread: boolean, accountContext?: ICodexAccountTelemetryContext): void { +export function reportCodexProviderSwitch(telemetryService: ITelemetryService, fromModelProvider: string | undefined, toModelProvider: string | undefined, isDesktopThread: boolean, accountContext?: ICodexAccountTelemetryContext, correlation?: IAgentTurnTelemetryCorrelation): void { const fromProvider = toSubscriptionProvider(fromModelProvider); const toProvider = toSubscriptionProvider(toModelProvider); if (!fromProvider || !toProvider || fromProvider === toProvider) { @@ -31,6 +34,7 @@ export function reportCodexProviderSwitch(telemetryService: ITelemetryService, f } telemetryService.publicLog2('agentHost.codexProviderSwitch', { + ...correlation, fromProvider, toProvider, isDesktopThread, diff --git a/src/vs/platform/agentHost/node/codex/codexReplayMapper.ts b/src/vs/platform/agentHost/node/codex/codexReplayMapper.ts index 117211ebfd4be5..837975bd212864 100644 --- a/src/vs/platform/agentHost/node/codex/codexReplayMapper.ts +++ b/src/vs/platform/agentHost/node/codex/codexReplayMapper.ts @@ -31,6 +31,7 @@ import { describeWebSearch, codexCompactionLabels, codexImageGenerationLabels, + codexImageGenerationToolInput, fileChangeOutput, mapCodexTurnError, turnStateFromStatus, @@ -386,7 +387,7 @@ function imageGenerationToolCallPart(item: Extract { + async write(session: URI, fields: ICodexSessionOverlayUpdate, strict = false): Promise { try { const ref = this._sessionDataService.openDatabase(session); const db = ref.object; @@ -157,6 +158,9 @@ export class CodexSessionMetadataStore { ref.dispose(); } } catch (err) { + if (strict) { + throw err; + } this._logService.warn(`[Codex] metadata write failed for ${session.toString()}: ${err instanceof Error ? err.message : String(err)}`); } } diff --git a/src/vs/platform/agentHost/node/codexCompactCommand.ts b/src/vs/platform/agentHost/node/codexCompactCommand.ts index b0932f67ec27a7..b9c64517e569e7 100644 --- a/src/vs/platform/agentHost/node/codexCompactCommand.ts +++ b/src/vs/platform/agentHost/node/codexCompactCommand.ts @@ -19,10 +19,13 @@ export class CodexCompactCompletionProvider implements IAgentHostCompletionItemP readonly kinds: ReadonlySet = new Set([CompletionItemKind.UserMessage]); readonly triggerCharacters = [CompletionTriggerCharacter.Slash] as const; - constructor(private readonly _hasHistory: (session: URI) => boolean) { } + constructor( + private readonly _hasHistory: (session: URI) => boolean, + private readonly _providerForSession: (session: URI) => string | undefined = AgentSession.provider, + ) { } async provideCompletionItems(params: CompletionsParams, _token: CancellationToken): Promise { - if (AgentSession.provider(params.channel) !== CODEX_AGENT_PROVIDER_ID || !this._hasHistory(params.channel)) { + if (this._providerForSession(params.channel) !== CODEX_AGENT_PROVIDER_ID || !this._hasHistory(params.channel)) { return []; } const leading = extractLeadingSlashToken(params.text, params.offset); diff --git a/src/vs/platform/agentHost/node/copilot/byokResponsesTranslation.ts b/src/vs/platform/agentHost/node/copilot/byokResponsesTranslation.ts index b8332c5003d79d..7b823581d222b2 100644 --- a/src/vs/platform/agentHost/node/copilot/byokResponsesTranslation.ts +++ b/src/vs/platform/agentHost/node/copilot/byokResponsesTranslation.ts @@ -18,6 +18,7 @@ interface IResponsesContentPart { readonly type?: string; readonly text?: string; readonly image_url?: string; + readonly detail?: string; readonly filename?: string; readonly file_data?: string; readonly file_id?: string; @@ -52,7 +53,7 @@ interface IResponsesInputItem { readonly name?: string; readonly arguments?: string; readonly input?: string; - readonly output?: string; + readonly output?: string | IResponsesContentPart[]; } interface IResponsesTool { @@ -105,7 +106,7 @@ function omittedFileText(part: IResponsesContentPart): string { return `\n[${name}${mimeType ? ` (${mimeType})` : ''} omitted: this model does not accept file inputs]\n`; } -function toContentParts(content: string | IResponsesContentPart[] | undefined, itemIndex: number): IByokLmContentPart[] { +function toContentParts(content: string | IResponsesContentPart[] | undefined, path: string): IByokLmContentPart[] { if (typeof content === 'string') { return content ? [{ type: 'text', text: content }] : []; } @@ -113,6 +114,7 @@ function toContentParts(content: string | IResponsesContentPart[] | undefined, i return []; } return content.map((part, contentIndex) => { + const partPath = `${path}[${contentIndex}]`; if ((part.type === 'input_text' || part.type === 'output_text' || part.type === 'text') && typeof part.text === 'string') { return { type: 'text' as const, text: part.text }; } @@ -120,12 +122,12 @@ function toContentParts(content: string | IResponsesContentPart[] | undefined, i const match = /^data:(?image\/[^;,]+)(?:;[^,]*)?;base64,(?.*)$/.exec(part.image_url); if (match?.groups) { if (!isSupportedImageMimeType(match.groups.mimeType)) { - throw new ResponsesTranslationError(`Unsupported input[${itemIndex}].content[${contentIndex}].image_url MIME type '${match.groups.mimeType}'`); + throw new ResponsesTranslationError(`Unsupported ${partPath}.image_url MIME type '${match.groups.mimeType}'`); } try { decodeBase64(match.groups.data); } catch { - throw new ResponsesTranslationError(`Invalid input[${itemIndex}].content[${contentIndex}].image_url`); + throw new ResponsesTranslationError(`Invalid ${partPath}.image_url`); } return { type: 'image' as const, @@ -133,15 +135,30 @@ function toContentParts(content: string | IResponsesContentPart[] | undefined, i data: match.groups.data, }; } - throw new ResponsesTranslationError(`Unsupported input[${itemIndex}].content[${contentIndex}].image_url`); + throw new ResponsesTranslationError(`Unsupported ${partPath}.image_url`); } if (part.type === 'input_file') { return { type: 'text' as const, text: omittedFileText(part) }; } - throw new ResponsesTranslationError(`Unsupported input[${itemIndex}].content[${contentIndex}] type '${part.type ?? ''}'`); + throw new ResponsesTranslationError(`Unsupported ${partPath} type '${part.type ?? ''}'`); }); } +/** + * Maps a Responses tool output to the bridge's text `output` and, when it + * carries images, the ordered `content` parts. The Copilot runtime sends a + * content-part array (`input_text` / `input_image`) instead of a string when a + * tool result carries images and the model declares vision support. + */ +function toToolOutput(output: string | IResponsesContentPart[] | undefined, path: string): { output: string; content?: IByokLmContentPart[] } { + if (!Array.isArray(output)) { + return { output: output ?? '' }; + } + const content = toContentParts(output, path); + const text = content.map(part => part.type === 'text' ? part.text : '').join(''); + return content.some(part => part.type === 'image') ? { output: text, content } : { output: text }; +} + function requiredString(value: string | undefined, path: string): string { if (!value) { throw new ResponsesTranslationError(`${path} is required`); @@ -155,7 +172,7 @@ function toBridgeInputItem(item: IResponsesInputItem, index: number): IByokLmInp return { type: 'message', role: toBridgeRole(item.role), - content: toContentParts(item.content, index), + content: toContentParts(item.content, `input[${index}].content`), }; case 'reasoning': return { @@ -180,7 +197,7 @@ function toBridgeInputItem(item: IResponsesInputItem, index: number): IByokLmInp return { type: 'function_call_output', callId: requiredString(item.call_id, `input[${index}].call_id`), - output: item.output ?? '', + ...toToolOutput(item.output, `input[${index}].output`), }; case 'custom_tool_call': return { @@ -193,7 +210,7 @@ function toBridgeInputItem(item: IResponsesInputItem, index: number): IByokLmInp return { type: 'custom_tool_call_output', callId: requiredString(item.call_id, `input[${index}].call_id`), - output: item.output ?? '', + ...toToolOutput(item.output, `input[${index}].output`), }; default: throw new ResponsesTranslationError(`Unsupported input[${index}] type '${item.type ?? ''}'`); diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 8c9186edbf4031..09984391351fa6 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -47,7 +47,7 @@ import { CopilotCliConfigKey, CopilotCliVSCodeAssignmentContextKey, copilotCliCo import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostByokModelsEnabledConfigKey, AgentHostMcpConnectorsEnabledConfigKey, AgentHostMcpServersConfigKey, AgentHostGitHubMcpServerEnabledConfigKey, AgentHostCopilotMultiRootEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AgentHostSystemProxyEnabledConfigKey, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostProxyConfigKey, agentHostProxyConfigSchema, AutoApproveLevel, SessionMode, migrateLegacyAutopilotConfig, platformRootSchema, platformSessionSchema, type AgentHostMcpServers } from '../../common/agentHostSchema.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; import { decodeProviderData, encodeProviderData, type IPersistedChat } from '../agentChatBackings.js'; -import { AgentChatOperationContext, AgentSession, AgentSignal, AuthenticateParams, COPILOT_CLI_AGENT_PROVIDER_ID, IActiveClient, IAgent, IAgentChatAdoptionResult, type IAgentAdoptedWorktree, IAgentChatConfigCompletionsParams, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, IAgentChats, IAgentLegacyChat, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentDescriptor, IAgentDiscoveredChat, IAgentHostManagedSettingsSnapshot, IAgentHostNetworkEndpoint, IAgentKnownSessionsFilter, IAgentMaterializeChatEvent, IAgentModelInfo, type IAgentPendingMessageSender, type IAgentPermissionResponseContext, type IAgentPluginUninstallRequest, IAgentResolveChatConfigParams, IAgentSessionProjectInfo, IAgentSpawnChatEvent, IMcpNotification, SubagentChatSignal, resolveAgentChatContext, resolveAgentHostCustomizations, resolveAgentHostInstructions, resolveSubagentChatParent, type IAgentTurnDiagnosticSnapshot, type IAgentTurnTokenUsage } from '../../common/agent.js'; +import { AgentChatOperationContext, AgentSession, AgentSignal, AuthenticateParams, COPILOT_CLI_AGENT_PROVIDER_ID, IActiveClient, IAgent, IAgentChatAdoptionResult, type IAgentAdoptedWorktree, IAgentChatConfigCompletionsParams, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, IAgentChats, IAgentLegacyChat, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentDescriptor, IAgentDiscoveredChat, IAgentHostManagedSettingsSnapshot, IAgentHostNetworkEndpoint, IAgentKnownSessionsFilter, IAgentMaterializeChatEvent, IAgentModelInfo, type IAgentPendingMessageSender, type IAgentPermissionResponseContext, type IAgentPluginUninstallRequest, IAgentResolveChatConfigParams, IAgentSessionProjectInfo, IAgentSpawnChatEvent, IMcpNotification, SubagentChatSignal, resolveAgentChatContext, resolveAgentHostCustomizations, resolveAgentHostInstructions, resolveSubagentChatParent, type IAgentTurnDiagnosticSnapshot, type IAgentTurnTokenUsage, type IAgentChatSessionEvent } from '../../common/agent.js'; import { getReasoningEffortDescription, getReasoningEffortLabel, resolveDefaultReasoningEffort } from '../../common/reasoningEffort.js'; import { autoModeTiers, defaultAutoModeTier, getAutoModeTierDescription, getAutoModeTierLabel } from '../../common/autoModeTiers.js'; import { AUTO_MODEL_ID, isAutoModel } from './modelIdentifiers.js'; @@ -82,9 +82,11 @@ import { McpServerStatus, type McpServerCustomization } from '../../common/state import { IAgentHostSessionTitleSignal } from '../agentHostSessionTitleSignal.js'; import { IByokLmBridgeRegistry } from '../byokLmBridgeRegistry.js'; import { IByokLmProxyService } from './byokLmProxyService.js'; -import { IAgentHostWorktreeIsolation, type IAgentHostWorktreeResumeService, SessionWorkingDirectoryMissingError } from '../shared/worktreeIsolation.js'; +import { detachedWorktreeRecordUri, IAgentHostWorktreeIsolation, type IAgentHostWorktreeResumeService, SessionWorkingDirectoryMissingError } from '../shared/worktreeIsolation.js'; +import { readSessionAdditionalWorktrees } from '../shared/sessionAdditionalWorktrees.js'; import { buildSessionEventLogFromTurns } from './buildSessionEvents.js'; import { CopilotAgentSession, type ICopilotWorkingDirectoryChangeTransaction } from './copilotAgentSession.js'; +import { readCopilotSessionMetadataEvents, toCopilotSessionMetadataEvent } from './copilotSessionMetadataEvents.js'; import { deferCopilotSdkExecution, getDeferredCopilotSdkExecution } from './copilotSessionExecutionMarker.js'; import { createCopilotCliEnvironment } from './copilotCliEnvironment.js'; import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitProject.js'; @@ -306,6 +308,10 @@ interface IWorkingDirectoryMetadataSnapshot { readonly workingDirectory: string | undefined; readonly workingDirectories: string | undefined; readonly customizationDirectory: string | undefined; + readonly sharedCustomizationDirectories?: string; + readonly projectUri?: string; + readonly projectDisplayName?: string; + readonly projectResolved?: string; } interface IWorkingDirectoryChangeTransactionOptions { @@ -316,6 +322,8 @@ interface IWorkingDirectoryChangeTransactionOptions { readonly previousCustomizationDirectory: URI; readonly previousCustomizationAdditionalDirectories: readonly URI[]; readonly previousMetadata: IWorkingDirectoryMetadataSnapshot; + readonly updateCustomizationAnchor?: boolean; + readonly sharedCustomizationDirectories?: readonly URI[]; } /** Stable empty host-customization snapshot used before the host publishes one. */ @@ -914,6 +922,8 @@ export class CopilotAgent extends Disposable implements IAgent { /** Fires when persisted chat backing data changes after creation. */ private readonly _onDidChangeChatData = this._register(new Emitter()); readonly onDidChangeChatData: Event = this._onDidChangeChatData.event; + private readonly _onDidChatSessionEvent = this._register(new Emitter()); + readonly onDidChatSessionEvent = this._onDidChatSessionEvent.event; private readonly _sessionLifetimes = new Map(); private readonly _pendingChatTurns = this._register(new DisposableMap>()); /** Provisional chats that defer SDK/session creation until the first send. */ @@ -1001,11 +1011,12 @@ export class CopilotAgent extends Disposable implements IAgent { this._register(this._onDidChatProgress.event(signal => this._emitSpawnedChatForSubagentSignal(signal))); this._register(completions.registerProvider(new CopilotSlashCommandCompletionProvider(this.id, { + ownsSession: session => [...this._chatScopes.values()].some(scope => isEqual(scope, URI.parse(session))) || AgentSession.provider(session) === this.id, isRubberDuckEnabled: () => this._isRubberDuckEnabled(), isLocalIndexEnabled: () => this._isLocalIndexEnabled(), getRuntimeSlashCommands: (sessionId, options) => this._getRuntimeSlashCommands(sessionId, options), getSessionCustomizations: (sessionId) => { - const session = AgentSession.uri(this.id, sessionId); + const session = this._sessionResourceForCompletions(sessionId); const chat = URI.parse(buildDefaultChatUri(session)); return this.getChatCustomizations(chat, { configurationResource: session, resource: chat }); }, @@ -1561,6 +1572,28 @@ export class CopilotAgent extends Disposable implements IAgent { }; } + async getRemoteControlManagedSettings(): Promise | undefined> { + const result = await (await this._ensureClient()).rpc.managedSettings.read(); + if (result.errorMessage) { + throw new Error('Unable to safely read device-managed settings'); + } + const settings = result.settingsJson; + if (settings === undefined) { + return undefined; + } + if (!settings || typeof settings !== 'object' || Array.isArray(settings)) { + throw new Error('Invalid device-managed settings document'); + } + const remoteControl = settings['remoteControl']; + if (remoteControl === undefined) { + return undefined; + } + if (!remoteControl || typeof remoteControl !== 'object' || Array.isArray(remoteControl)) { + throw new Error('Invalid device remote-control policy'); + } + return remoteControl; + } + getCustomizations(): readonly Customization[] { return this._plugins.getConfiguredHostCustomizations(); } @@ -1609,27 +1642,39 @@ export class CopilotAgent extends Disposable implements IAgent { } async setWorkingDirectory(chat: URI, context: URI | IAgentChatContext, workingDirectory: URI): Promise { + return this._setWorkingDirectory(chat, context, workingDirectory, false); + } + + async setChatWorkingDirectory(chat: URI, context: IAgentChatContext, workingDirectory: URI, options?: { readonly replaceSessionWorkspace: boolean }): Promise { + return this._setWorkingDirectory(chat, context, workingDirectory, true, options?.replaceSessionWorkspace); + } + + private async _setWorkingDirectory(chat: URI, context: URI | IAgentChatContext, workingDirectory: URI, chatOnly: boolean, replaceSessionWorkspace = false): Promise { const initial = this._resolveLiveWorkingDirectoryContext(chat, context); - if (!isDefaultChatUri(chat)) { + if (!chatOnly && !isDefaultChatUri(chat)) { throw new Error(`Cannot change the working directory for peer chat '${chat.toString()}': live working-directory changes are only supported for the owning default chat`); } const existingMutation = this._workingDirectoryMutations.get(initial.configurationResource); if (existingMutation) { throw new Error(`Cannot change the working directory for chat '${chat.toString()}' while another working-directory change is active for its configuration`); } - this._throwIfRecordedChatSharesConfiguration(chat, initial.configurationResource); - this._workingDirectoryMutations.set(initial.configurationResource, initial.entry); + if (!chatOnly || replaceSessionWorkspace) { + this._throwIfRecordedChatSharesConfiguration(chat, initial.configurationResource); + this._workingDirectoryMutations.set(initial.configurationResource, initial.entry); + } try { await this._queueChat(initial.configurationId, initial.sdkSessionId, 'setWorkingDirectory', async () => { const current = this._resolveLiveWorkingDirectoryContext(chat, context); if (current.entry !== initial.entry || current.sdkSessionId !== initial.sdkSessionId) { throw new Error(`Cannot change the working directory: chat '${chat.toString()}' is no longer backed by the same live session`); } - this._throwIfRecordedChatSharesConfiguration(chat, current.configurationResource); - for (const candidate of this._chatEntriesBySdkId.values()) { - const sibling = candidate.chatSession; - if (sibling !== current.entry && isEqual(sibling.ownerSessionUri ?? sibling.sessionUri, current.configurationResource)) { - throw new Error(`Cannot change the working directory for chat '${chat.toString()}' while another live chat shares its configuration`); + if (!chatOnly || replaceSessionWorkspace) { + this._throwIfRecordedChatSharesConfiguration(chat, current.configurationResource); + for (const candidate of this._chatEntriesBySdkId.values()) { + const sibling = candidate.chatSession; + if (sibling !== current.entry && isEqual(sibling.ownerSessionUri ?? sibling.sessionUri, current.configurationResource)) { + throw new Error(`Cannot change the working directory for chat '${chat.toString()}' while another live chat shares its configuration`); + } } } if (workingDirectory.scheme !== Schemas.file || !isAbsolute(workingDirectory.fsPath)) { @@ -1646,7 +1691,7 @@ export class CopilotAgent extends Disposable implements IAgent { } const storedMetadata = await this._readWorkingDirectoryMetadata(resource); - if (entry.appliedAdditionalDirectories.length > 0 || activeClient.pluginController.additionalDirectories.length > 0 || (storedMetadata.workingDirectories?.length ?? 0) > 1) { + if (entry.appliedAdditionalDirectories.length > 0 || (!chatOnly && activeClient.pluginController.additionalDirectories.length > 0) || (storedMetadata.workingDirectories?.length ?? 0) > 1) { throw new Error(`Cannot change the working directory for multi-root chat '${chat.toString()}'`); } @@ -1657,6 +1702,9 @@ export class CopilotAgent extends Disposable implements IAgent { if (isEqual(previousWorkingDirectory, workingDirectory)) { return; } + if (isEqual(resource, configurationResource)) { + await this._pinSiblingWorkingDirectories(chat, configurationResource, previousWorkingDirectory); + } const previousCustomizationDirectory = activeClient.pluginController.directory ?? previousWorkingDirectory; const previousCustomizationAdditionalDirectories = [...activeClient.pluginController.additionalDirectories]; @@ -1668,6 +1716,10 @@ export class CopilotAgent extends Disposable implements IAgent { previousCustomizationDirectory, previousCustomizationAdditionalDirectories, previousMetadata: storedMetadata.snapshot, + updateCustomizationAnchor: !chatOnly || replaceSessionWorkspace, + sharedCustomizationDirectories: chatOnly && !replaceSessionWorkspace && isEqual(resource, configurationResource) + ? [previousCustomizationDirectory, ...previousCustomizationAdditionalDirectories] + : undefined, }); await entry.setWorkingDirectory(workingDirectory, transaction); }); @@ -1678,32 +1730,82 @@ export class CopilotAgent extends Disposable implements IAgent { } } + /** + * A peer chat without its own working-directory metadata resumes in the + * directory its configuration scope stores, which is the main chat's. Pin + * such siblings to their current directory before that shared metadata + * moves so a later cold resume cannot silently follow the main chat. + */ + private async _pinSiblingWorkingDirectories(movedChat: URI, configurationResource: URI, sharedWorkingDirectory: URI): Promise { + for (const [siblingKey, siblingConfiguration] of [...this._chatScopes]) { + const storageScope = this._chatStorageScopes.get(siblingKey); + if (siblingKey === movedChat.toString() || !storageScope || !isEqual(siblingConfiguration, configurationResource) || isEqual(storageScope, configurationResource)) { + continue; + } + if ((await this._readSessionMetadata(storageScope)).workingDirectories) { + continue; + } + const workingDirectory = this._findChatByUri(siblingKey)?.workingDirectory ?? sharedWorkingDirectory; + // Atomic so a peer that persisted its own directory since the read above is never overwritten with this one. + const dbRef = this._sessionDataService.openDatabase(storageScope); + let pinned: boolean; + try { + pinned = await dbRef.object.setMetadataValuesIfAbsent(CopilotAgent._META_CWD, { + [CopilotAgent._META_CWD]: workingDirectory.toString(), + [CopilotAgent._META_CWDS]: JSON.stringify([workingDirectory.toString()]), + }); + } finally { + dbRef.dispose(); + } + // A declined write means a value exists; only empty placeholder rows left by a rolled-back move still need the pin. + if (!pinned && !(await this._readSessionMetadata(storageScope)).workingDirectories) { + await this._storeSessionMetadata(storageScope, undefined, workingDirectory, [workingDirectory], undefined, undefined); + } + } + } + private _createWorkingDirectoryChangeTransaction(options: IWorkingDirectoryChangeTransactionOptions): ICopilotWorkingDirectoryChangeTransaction { const { resource, + previousMetadata, activeClient, workingDirectory, previousWorkingDirectory, previousCustomizationDirectory, previousCustomizationAdditionalDirectories, - previousMetadata, } = options; - const metadataFor = (directory: URI): IWorkingDirectoryMetadataSnapshot => ({ - workingDirectory: directory.toString(), - workingDirectories: JSON.stringify([directory.toString()]), - customizationDirectory: directory.toString(), - }); + const metadataFor = async (directory: URI): Promise => { + const project = options.updateCustomizationAnchor !== false + ? await projectFromCopilotContext({ cwd: directory.fsPath }, this._gitService) + : undefined; + return { + ...previousMetadata, + workingDirectory: directory.toString(), + workingDirectories: JSON.stringify([directory.toString()]), + customizationDirectory: directory.toString(), + sharedCustomizationDirectories: options.sharedCustomizationDirectories + ? JSON.stringify(options.sharedCustomizationDirectories.map(directory => directory.toString())) + : undefined, + ...(options.updateCustomizationAnchor !== false ? { + projectUri: project?.uri.toString(), + projectDisplayName: project?.displayName, + projectResolved: 'true', + } : {}), + }; + }; const applyProviderState = async (directory: URI, additionalDirectories: readonly URI[], metadata: IWorkingDirectoryMetadataSnapshot): Promise => { const errors: string[] = []; - try { - activeClient.pluginController.reanchor(directory); - } catch (error) { - errors.push(`customization anchor: ${getErrorMessage(error)}`); - } - try { - activeClient.pluginController.setAdditionalDirectories(additionalDirectories); - } catch (error) { - errors.push(`customization additional roots: ${getErrorMessage(error)}`); + if (options.updateCustomizationAnchor !== false) { + try { + activeClient.pluginController.reanchor(directory); + } catch (error) { + errors.push(`customization anchor: ${getErrorMessage(error)}`); + } + try { + activeClient.pluginController.setAdditionalDirectories(additionalDirectories); + } catch (error) { + errors.push(`customization additional roots: ${getErrorMessage(error)}`); + } } try { await this._storeWorkingDirectoryMetadataSnapshot(resource, metadata); @@ -1716,11 +1818,11 @@ export class CopilotAgent extends Disposable implements IAgent { }; return { - prepare: () => applyProviderState(workingDirectory, [], metadataFor(workingDirectory)), + prepare: async () => applyProviderState(workingDirectory, [], await metadataFor(workingDirectory)), rollback: () => applyProviderState(previousCustomizationDirectory, previousCustomizationAdditionalDirectories, previousMetadata), - reconcile: authoritativeWorkingDirectory => isEqual(authoritativeWorkingDirectory, previousWorkingDirectory) + reconcile: async authoritativeWorkingDirectory => isEqual(authoritativeWorkingDirectory, previousWorkingDirectory) ? applyProviderState(previousCustomizationDirectory, previousCustomizationAdditionalDirectories, previousMetadata) - : applyProviderState(authoritativeWorkingDirectory, [], metadataFor(authoritativeWorkingDirectory)), + : applyProviderState(authoritativeWorkingDirectory, [], await metadataFor(authoritativeWorkingDirectory)), }; } @@ -1915,15 +2017,13 @@ export class CopilotAgent extends Disposable implements IAgent { } const entry = this._findSessionChat(session); if (entry) { - // For non-provisional sessions the anchor follows the working directory - // (the worktree). Prefer it over a persisted `customizationDirectory`, - // which older sessions stored as the original user-picked folder. - return { directory: entry.customizationDirectory, additionalDirectories: [], applyAdditional: false }; + const directory = this._activeClients.get(session)?.pluginController.directory ?? entry.customizationDirectory; + return { directory, additionalDirectories: [], applyAdditional: false }; } const metadata = await this._readSessionMetadata(session); return { - directory: metadata.workingDirectory ?? metadata.customizationDirectory, - additionalDirectories: this._additionalCustomizationDirectories(metadata.workingDirectories), + directory: metadata.sharedCustomizationDirectories?.[0] ?? metadata.workingDirectory ?? metadata.customizationDirectory, + additionalDirectories: this._additionalCustomizationDirectories(metadata.sharedCustomizationDirectories ?? metadata.workingDirectories), applyAdditional: true, }; } @@ -2984,6 +3084,22 @@ export class CopilotAgent extends Disposable implements IAgent { return await this._fileService.exists(resource) ? resource : undefined; } + async *readChatSessionEvents(chat: URI, context: IAgentChatContext, token: CancellationToken, afterEventId?: string, onDidReadEventId?: (id: string) => void): AsyncIterable { + const resource = await this.getSessionStateFile(context.configurationResource, chat); + if (resource) { + yield* readCopilotSessionMetadataEvents(chat, resource.fsPath, token, afterEventId, onDidReadEventId); + } + } + + async synchronizeChatSessionTitle(chat: URI, title: string): Promise { + const entry = this._findChatByUri(chat); + if (!entry) { + return false; + } + await entry.synchronizeTitle(title); + return true; + } + private readonly _copilotChatDiscovery: CopilotChatDiscovery; private readonly _discoveredChats = new Map(); @@ -3675,15 +3791,20 @@ export class CopilotAgent extends Disposable implements IAgent { } private _getRuntimeSlashCommands(sessionId: string, options?: ICopilotRuntimeSlashCommandQueryOptions) { - const session = this._findSessionBySdkId(sessionId); + const session = this._findSessionChat(this._sessionResourceForCompletions(sessionId)) ?? this._findSessionBySdkId(sessionId); if (session) { return session.getRuntimeSlashCommands(options) ?? []; } + return this._slashCommandProvider.getSlashCommands(options); } + private _sessionResourceForCompletions(sessionId: string): URI { + return [...this._chatScopes.values()].find(scope => AgentSession.id(scope) === sessionId) ?? AgentSession.uri(this.id, sessionId); + } + private async _listPluginMarketplaces(sessionId: string) { - const session = this._findSessionChat(AgentSession.uri(this.id, sessionId)); + const session = this._findSessionChat(this._sessionResourceForCompletions(sessionId)); if (session) { return session.listPluginMarketplaces(); } @@ -3692,7 +3813,7 @@ export class CopilotAgent extends Disposable implements IAgent { } private async _listInstalledPlugins(sessionId: string) { - const session = this._findSessionChat(AgentSession.uri(this.id, sessionId)); + const session = this._findSessionChat(this._sessionResourceForCompletions(sessionId)); if (session) { return session.listInstalledPlugins(); } @@ -3701,7 +3822,7 @@ export class CopilotAgent extends Disposable implements IAgent { } private async _listPluginMarketplacePlugins(sessionId: string): Promise { - const session = this._findSessionChat(AgentSession.uri(this.id, sessionId)); + const session = this._findSessionChat(this._sessionResourceForCompletions(sessionId)); if (session) { return session.listPluginMarketplacePlugins(); } @@ -3971,31 +4092,15 @@ export class CopilotAgent extends Disposable implements IAgent { const clientTelemetryContext = URI.isUri(operationContext) ? undefined : operationContext.clientTelemetryContext; await this._queueChatTurn(context, 'resumeTurn', turnId, async token => { const current = this._resolveChatContext(chat, operationContext); - let entry = current.target ?? await this._ensureResolvedChatSession(current); + const cachedEntry = current.target; + let entry = cachedEntry ?? await this._ensureResolvedChatSession(current); if (!entry) { throw new Error(`[Copilot] resumeTurn for unknown chat: ${chat.toString()}`); } - const activeClient = this._activeClients.get(current.configurationResource); - const currentSnapshot = activeClient ? await raceCancellationError(activeClient.snapshot(current.chatKey), token) : undefined; - const refreshReason = entry.requiresRestartAfterWorkingDirectoryChange - ? 'workingDirectoryChanged' - : entry.requiresRestartAfterModelChange - ? 'hydraFusionModelChanged' - : activeClient && currentSnapshot - ? await raceCancellationError(activeClient.getRestartReason(entry.appliedSnapshot, current.chatKey, currentSnapshot), token) - : undefined; - if (token.isCancellationRequested) { - throw new CancellationError(); - } - if (refreshReason) { - this._logService.info(`[Copilot:${current.configurationId}] Session configuration changed, refreshing session: operation=resumeTurn, sdkSessionId=${entry.sessionId}, chat=${current.chatKey}, turnId=${turnId}, reason=${refreshReason}`); - await this._destroyLiveSession(entry, true); - entry = entry.sessionId === current.configurationId - ? await this._resumeSession(current.configurationId, current.chat) - : await this._ensureResolvedChatSession(current); - } - if (!entry) { - throw new Error(`[Copilot] resumeTurn for unavailable chat: ${chat.toString()}`); + entry = await this._refreshSessionConfiguration(current, entry, undefined, { operation: 'resumeTurn', allowRestart: 'always', turnId, token }); + // The refresh already compared a warm runtime it left in place with the stored directory. + if (entry !== cachedEntry) { + await this._assertTurnWorkingDirectory(current, entry); } if (token.isCancellationRequested) { throw new CancellationError(); @@ -4746,11 +4851,11 @@ export class CopilotAgent extends Disposable implements IAgent { context: IResolvedCopilotChatContext, entry: CopilotAgentSession, workingDirectories: readonly URI[] | undefined, - options: { readonly operation: 'sendMessage' | 'startMcpServer'; readonly allowRestart: 'whenIdle' | 'always'; readonly turnId?: string; readonly token?: CancellationToken; readonly launchedForTurn?: boolean }, + options: { readonly operation: 'sendMessage' | 'resumeTurn' | 'startMcpServer'; readonly allowRestart: 'whenIdle' | 'always'; readonly turnId?: string; readonly token?: CancellationToken; readonly launchedForTurn?: boolean }, ): Promise { const activeClient = this._activeClients.get(context.configurationResource); // MCP Stop still needs the queued sync to finish before it can resolve the server to stop. - const waitToken = options.operation === 'sendMessage' ? options.token ?? CancellationToken.None : CancellationToken.None; + const waitToken = options.operation !== 'startMcpServer' ? options.token ?? CancellationToken.None : CancellationToken.None; const { operation, allowRestart, turnId, launchedForTurn } = options; const rootsChanged = workingDirectories !== undefined && !areAdditionalWorkingDirectoriesEqual(entry.appliedAdditionalDirectories, this._additionalCustomizationDirectories(workingDirectories)); // The turn `prepareTurn` launched for revalidates against the state already @@ -4762,10 +4867,18 @@ export class CopilotAgent extends Disposable implements IAgent { if (!useObservedState) { await activeClient?.pluginController.retryFailedClientSyncIfNeeded(waitToken); } - const currentSnapshot = !activeClient ? undefined - : useObservedState ? activeClient.currentSnapshot(context.chatKey) - : await raceCancellationError(activeClient.snapshot(context.chatKey), waitToken); - const structuralRestartReason = activeClient && currentSnapshot ? await raceCancellationError(activeClient.getRestartReason(entry.appliedSnapshot, context.chatKey, currentSnapshot), waitToken) : undefined; + // The stored directory is deliberately read on every operation: other writers (peer pins, rolled-back moves, chat migration) can + // change it without any signal this entry could observe, so the read overlaps the snapshot work instead of being cached. + const [storedWorkingDirectory, currentSnapshot] = await Promise.all([ + this._readStoredWorkingDirectory(context.resource), + !activeClient ? undefined + : useObservedState ? activeClient.currentSnapshot(context.chatKey) + : raceCancellationError(activeClient.snapshot(context.chatKey), waitToken), + ]); + const primaryDirectoryChanged = !!storedWorkingDirectory && !isEqual(entry.workingDirectory, storedWorkingDirectory); + const structuralRestartReason = !entry.requiresRestartAfterWorkingDirectoryChange && !entry.requiresRestartAfterModelChange && activeClient && currentSnapshot + ? await raceCancellationError(activeClient.getRestartReason(entry.appliedSnapshot, context.chatKey, currentSnapshot), waitToken) + : undefined; const currentDisabledRootMcpServers = currentSnapshot ? await raceCancellationError(this._disabledRootMcpServers(context.configurationResource, entry.sessionId, currentSnapshot), waitToken) : undefined; @@ -4774,6 +4887,7 @@ export class CopilotAgent extends Disposable implements IAgent { [...new Set(currentDisabledRootMcpServers)].sort(), ); const refreshReason = (entry.requiresRestartAfterWorkingDirectoryChange ? 'workingDirectoryChanged' : undefined) + ?? (primaryDirectoryChanged ? 'primaryDirectoryChanged' : undefined) ?? (entry.requiresRestartAfterModelChange ? 'hydraFusionModelChanged' : undefined) ?? (rootsChanged ? 'additionalDirectoriesChanged' : undefined) ?? structuralRestartReason @@ -4795,9 +4909,6 @@ export class CopilotAgent extends Disposable implements IAgent { if (entry.sessionId === context.configurationId) { return this._resumeSession(context.configurationId, context.chat, workingDirectories); } - if (workingDirectories) { - activeClient?.pluginController.setAdditionalDirectories(this._additionalCustomizationDirectories(workingDirectories)); - } const refreshed = await this._ensureResolvedChatSession(context, workingDirectories); if (!refreshed) { throw new Error(`Cannot refresh Copilot session configuration for chat: ${context.chat.toString()}`); @@ -4805,6 +4916,13 @@ export class CopilotAgent extends Disposable implements IAgent { return refreshed; } + private async _assertTurnWorkingDirectory(context: IResolvedCopilotChatContext, entry: CopilotAgentSession): Promise { + const storedWorkingDirectory = await this._readStoredWorkingDirectory(context.resource); + if (storedWorkingDirectory && !isEqual(entry.workingDirectory, storedWorkingDirectory)) { + throw new Error(`Cannot send to Copilot chat '${context.chat.toString()}': its working directory is unavailable; the runtime was opened against a fallback directory for history only`); + } + } + private async _sendMessageOnce(chat: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, senderClientId?: string, clientType = AgentHostClientType.Unknown, workingDirectories?: readonly URI[], operationContext?: URI | IAgentChatContext, clientTelemetryContext?: IAgentHostClientTelemetryContext): Promise { const context = this._resolveSendChatContext(chat, operationContext); const stageRecorder = operationContext && !URI.isUri(operationContext) ? operationContext.sendStageRecorder : undefined; @@ -4834,6 +4952,10 @@ export class CopilotAgent extends Disposable implements IAgent { if (!entry) { throw new Error(`[Copilot] sendMessage for unknown chat: ${chat.toString()}`); } + // The refresh already compared a warm runtime it left in place with the stored directory. + if (entry !== current.target) { + await this._assertTurnWorkingDirectory(current, entry); + } if (token.isCancellationRequested) { throw new CancellationError(); } @@ -4894,7 +5016,7 @@ export class CopilotAgent extends Disposable implements IAgent { * be a no-op (e.g. `/autopilot on` while already in autopilot). */ private _getSessionConfigState(sessionId: string): ICopilotConfigSlashCommandState { - const sessionKey = AgentSession.uri(this.id, sessionId).toString(); + const sessionKey = this._sessionResourceForCompletions(sessionId).toString(); return { mode: this._configurationService.getEffectiveValue(sessionKey, platformSessionSchema, SessionConfigKey.Mode), autoApprove: this._configurationService.getEffectiveValue(sessionKey, platformSessionSchema, SessionConfigKey.AutoApprove), @@ -5077,7 +5199,11 @@ export class CopilotAgent extends Disposable implements IAgent { // SDK chat. Keying it by the chat URI instead would // snapshot empty/stale tools and never see subsequent updates, and // would also leak (nothing disposes a chat-keyed ActiveClient). - const activeClient = this._getOrCreateActiveClient(session, workingDirectory); + const anchors = await this._getSessionCustomizationAnchors(session); + const activeClient = this._getOrCreateActiveClient(session, anchors.directory ?? workingDirectory); + if (anchors.applyAdditional) { + activeClient.pluginController.setAdditionalDirectories(anchors.additionalDirectories); + } const snapshot = await activeClient.snapshot(chatKey); const shellManager = this._instantiationService.createInstance(ShellManager, chat, workingDirectory); // The database copy lands in the storage scope Agent Host chose for @@ -5620,17 +5746,22 @@ export class CopilotAgent extends Disposable implements IAgent { return undefined; } stageRecorder?.mark('snapshot'); - const storedMetadata = await this._readSessionMetadata(configurationResource); - const resumeWorkingDirectories = this._workingDirectoriesForResume(storedMetadata, workingDirectories); + const storedMetadata = await this._readSessionMetadata(context.resource); + const configurationMetadata = isEqual(context.resource, configurationResource) + ? storedMetadata + : await this._readSessionMetadata(configurationResource); + const resumeWorkingDirectories = this._workingDirectoriesForResume(storedMetadata, workingDirectories) ?? storedMetadata.workingDirectories; const parentEntry = this._findSessionBySdkId(configurationId); - const persistedWorkingDirectory = resumeWorkingDirectories?.[0] ?? parentEntry?.workingDirectory + const persistedWorkingDirectory = resumeWorkingDirectories?.[0] ?? storedMetadata.workingDirectory ?? parentEntry?.workingDirectory ?? this._provisionalSessions.get(configurationId)?.workingDirectory - ?? storedMetadata.workingDirectory; + ?? configurationMetadata.workingDirectory; if (!persistedWorkingDirectory) { this._logService.warn(`[Copilot] Cannot resume chat ${chatKey}: missing working directory`); return undefined; } - const workingDirectory = await this._worktree.resolveWorkingDirectoryForResume(configurationResource, AgentSession.id(configurationResource), persistedWorkingDirectory); + const workspaceResource = isEqual(persistedWorkingDirectory, configurationMetadata.workingDirectory) + ? configurationResource : context.resource; + const { workingDirectory, detached } = await this._resolveOwnedWorkingDirectoryForResume(configurationResource, workspaceResource, configurationId, persistedWorkingDirectory); const launchWorkingDirectories = resumeWorkingDirectories ? [workingDirectory, ...resumeWorkingDirectories.slice(1)] : undefined; @@ -5638,8 +5769,11 @@ export class CopilotAgent extends Disposable implements IAgent { const client = await this._ensureClientForSession(); this._throwIfWorkingDirectoryMutationBlocksChat(configurationResource, chat); stageRecorder?.mark('snapshot'); - const activeClient = this._getOrCreateActiveClient(configurationResource, workingDirectory); - activeClient.pluginController.reanchor(workingDirectory); + const anchors = await this._getSessionCustomizationAnchors(configurationResource); + const activeClient = this._getOrCreateActiveClient(configurationResource, anchors.directory); + if (anchors.applyAdditional) { + activeClient.pluginController.setAdditionalDirectories(anchors.additionalDirectories); + } const snapshot = await activeClient.snapshot(chatKey); const shellManager = this._instantiationService.createInstance(ShellManager, chat, workingDirectory); const launchPlan: CopilotSessionLaunchPlan = { @@ -5666,7 +5800,7 @@ export class CopilotAgent extends Disposable implements IAgent { this._registerLiveChat(chat, initializingSession, activeClient); }); stageRecorder?.mark('persist'); - if (launchWorkingDirectories) { + if (launchWorkingDirectories && (!detached || isEqual(workingDirectory, persistedWorkingDirectory))) { await this._storeSessionMetadata(context.resource, info.model, workingDirectory, launchWorkingDirectories, undefined, undefined); } this._logService.info(`[Copilot] Resumed chat backing ${chatKey} for configuration ${configurationResource.toString()}`); @@ -5681,6 +5815,20 @@ export class CopilotAgent extends Disposable implements IAgent { }); } + private async _resolveOwnedWorkingDirectoryForResume(configurationResource: URI, workspaceResource: URI, sessionId: string, persistedWorkingDirectory: URI): Promise<{ readonly workingDirectory: URI; readonly detached: boolean }> { + const detachedWorktree = (await readSessionAdditionalWorktrees(this._sessionDataService, configurationResource).catch(error => { + // A corrupt record must not block an otherwise valid resume or mask a missing-directory error. + this._logService.warn(`[Copilot:${sessionId}] Ignoring unreadable additional worktree metadata on resume: ${getErrorMessage(error)}`); + return []; + })).find(worktree => isEqual(URI.parse(worktree.workingDirectory), persistedWorkingDirectory)); + const workingDirectory = await this._worktree.resolveWorkingDirectoryForResume( + detachedWorktree ? detachedWorktreeRecordUri(detachedWorktree.handle) : workspaceResource, + detachedWorktree?.handle ?? sessionId, + persistedWorkingDirectory, + ); + return { workingDirectory, detached: !!detachedWorktree }; + } + private _throwIfWorkingDirectoryMutationBlocksChat(configurationResource: URI, chat: URI): void { const mutationOwner = this._workingDirectoryMutations.get(configurationResource); if (mutationOwner && this._findChatByUri(chat) !== mutationOwner) { @@ -6066,6 +6214,14 @@ export class CopilotAgent extends Disposable implements IAgent { ...(identity?.resource ? { resource: identity.resource } : {}), rawSessionId: launchPlan.sessionId, onDidSessionProgress: this._onDidChatProgress, + onSessionEvent: event => { + if (this._onDidChatSessionEvent.hasListeners()) { + const metadata = toCopilotSessionMetadataEvent(chatChannelUri, event); + if (metadata) { + this._onDidChatSessionEvent.fire(metadata); + } + } + }, sessionLauncher: this._sessionLauncher, launchPlan, shellManager: launchPlan.shellManager, @@ -6265,13 +6421,10 @@ export class CopilotAgent extends Disposable implements IAgent { if (storedMetadata.workspaceless) { await this._ensureWorkspacelessScratchDir(workingDirectory, sessionId); } else { - resolvedWorkingDirectory = await this._worktree.resolveWorkingDirectoryForResume(sessionUri, AgentSession.id(sessionUri), workingDirectory); + resolvedWorkingDirectory = (await this._resolveOwnedWorkingDirectoryForResume(sessionUri, sessionUri, sessionId, workingDirectory)).workingDirectory; } - // Anchor customization discovery to the working directory (the worktree for - // worktree-isolated sessions), matching how the session was materialized. - // Older sessions persisted `customizationDirectory` as the user-picked - // folder; preferring the working directory corrects them on resume. - const customizationDirectory = resolvedWorkingDirectory; + // Only an explicit chat-only move decouples shared discovery from the main workspace. + const customizationDirectory = storedMetadata.sharedCustomizationDirectories?.[0] ?? resolvedWorkingDirectory; // Always create an ActiveClient so the snapshot includes host + // session-discovered customizations, even when no client has // registered an active-client handle yet. @@ -6281,7 +6434,7 @@ export class CopilotAgent extends Disposable implements IAgent { // root on resume. Empty when single-root / gated off. A send-time // snapshot supersedes the persisted restoration seed. const launchWorkingDirectories = this._workingDirectoriesForResume(storedMetadata, workingDirectories) ?? storedMetadata.workingDirectories; - activeClient.pluginController.setAdditionalDirectories(this._additionalCustomizationDirectories(launchWorkingDirectories)); + activeClient.pluginController.setAdditionalDirectories(this._additionalCustomizationDirectories(storedMetadata.sharedCustomizationDirectories ?? launchWorkingDirectories)); // Prefer chat-scoped membership when this SDK session is already bound to a chat. const snapshot = await activeClient.snapshot(this._findBoundSessionChatUri(sessionId)?.toString()); @@ -6352,6 +6505,8 @@ export class CopilotAgent extends Disposable implements IAgent { /** Persisted ordered working-directory set (JSON array of URI strings; index 0 = primary). */ private static readonly _META_CWDS = 'copilot.workingDirectories'; private static readonly _META_CUSTOMIZATION_DIRECTORY = 'copilot.customizationDirectory'; + /** Shared discovery roots retained when only the main chat changes workspace. */ + private static readonly _META_SHARED_CUSTOMIZATION_DIRECTORIES = 'copilot.sharedCustomizationDirectories'; private static readonly _META_PROJECT_RESOLVED = 'copilot.project.resolved'; private static readonly _META_PROJECT_URI = 'copilot.project.uri'; private static readonly _META_PROJECT_DISPLAY_NAME = 'copilot.project.displayName'; @@ -6472,6 +6627,10 @@ export class CopilotAgent extends Disposable implements IAgent { [CopilotAgent._META_CWD]: true, [CopilotAgent._META_CWDS]: true, [CopilotAgent._META_CUSTOMIZATION_DIRECTORY]: true, + [CopilotAgent._META_SHARED_CUSTOMIZATION_DIRECTORIES]: true, + [CopilotAgent._META_PROJECT_URI]: true, + [CopilotAgent._META_PROJECT_DISPLAY_NAME]: true, + [CopilotAgent._META_PROJECT_RESOLVED]: true, }); const workingDirectory = metadata[CopilotAgent._META_CWD]; const customizationDirectory = metadata[CopilotAgent._META_CUSTOMIZATION_DIRECTORY]; @@ -6483,6 +6642,10 @@ export class CopilotAgent extends Disposable implements IAgent { workingDirectory, workingDirectories: metadata[CopilotAgent._META_CWDS], customizationDirectory, + sharedCustomizationDirectories: metadata[CopilotAgent._META_SHARED_CUSTOMIZATION_DIRECTORIES], + projectUri: metadata[CopilotAgent._META_PROJECT_URI], + projectDisplayName: metadata[CopilotAgent._META_PROJECT_DISPLAY_NAME], + projectResolved: metadata[CopilotAgent._META_PROJECT_RESOLVED], }, }; } finally { @@ -6497,6 +6660,10 @@ export class CopilotAgent extends Disposable implements IAgent { [CopilotAgent._META_CWD]: metadata.workingDirectory ?? '', [CopilotAgent._META_CWDS]: metadata.workingDirectories ?? '', [CopilotAgent._META_CUSTOMIZATION_DIRECTORY]: metadata.customizationDirectory ?? '', + [CopilotAgent._META_SHARED_CUSTOMIZATION_DIRECTORIES]: metadata.sharedCustomizationDirectories ?? '', + [CopilotAgent._META_PROJECT_URI]: metadata.projectUri ?? '', + [CopilotAgent._META_PROJECT_DISPLAY_NAME]: metadata.projectDisplayName ?? '', + [CopilotAgent._META_PROJECT_RESOLVED]: metadata.projectResolved ?? '', }); } finally { dbRef.dispose(); @@ -6527,7 +6694,21 @@ export class CopilotAgent extends Disposable implements IAgent { return fallback ? [fallback] : undefined; } - private async _readSessionMetadata(session: URI): Promise<{ model?: ModelSelection; agent?: AgentSelection; workingDirectory?: URI; workingDirectories?: readonly URI[]; customizationDirectory?: URI; workspaceless?: boolean }> { + /** Reads only the persisted primary working directory; cheaper than {@link _readSessionMetadata} for per-operation staleness checks. */ + private async _readStoredWorkingDirectory(session: URI): Promise { + const ref = await this._sessionDataService.tryOpenDatabase(session); + if (!ref) { + return undefined; + } + try { + const workingDirectory = await ref.object.getMetadata(CopilotAgent._META_CWD); + return workingDirectory ? URI.parse(workingDirectory) : undefined; + } finally { + ref.dispose(); + } + } + + private async _readSessionMetadata(session: URI): Promise<{ model?: ModelSelection; agent?: AgentSelection; workingDirectory?: URI; workingDirectories?: readonly URI[]; customizationDirectory?: URI; sharedCustomizationDirectories?: readonly URI[]; workspaceless?: boolean }> { const ref = await this._sessionDataService.tryOpenDatabase(session); if (!ref) { return {}; @@ -6539,6 +6720,7 @@ export class CopilotAgent extends Disposable implements IAgent { [CopilotAgent._META_CWD]: true, [CopilotAgent._META_CWDS]: true, [CopilotAgent._META_CUSTOMIZATION_DIRECTORY]: true, + [CopilotAgent._META_SHARED_CUSTOMIZATION_DIRECTORIES]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, }); const cwd = m[CopilotAgent._META_CWD]; @@ -6550,6 +6732,7 @@ export class CopilotAgent extends Disposable implements IAgent { workingDirectory, workingDirectories: this._parseWorkingDirectories(m[CopilotAgent._META_CWDS], workingDirectory), customizationDirectory: customizationDirectory ? URI.parse(customizationDirectory) : undefined, + sharedCustomizationDirectories: this._parseWorkingDirectories(m[CopilotAgent._META_SHARED_CUSTOMIZATION_DIRECTORIES], undefined), workspaceless: m[AH_META_WORKSPACELESS_DB_KEY] === 'true', }; } finally { diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index aa4bb617b0c65f..0941f74f6ebfb8 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -47,7 +47,7 @@ import { getSessionSandboxConfig } from '../sessionSandbox.js'; import { getCopilotBrowserSandboxNetworkRestrictions } from './copilotSandboxPolicy.js'; import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyAnswer, AgentHostAutoReplyEnabledConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, AgentHostMcpToolRoutingEnabledConfigKey, platformRootSchema, platformSessionSchema } from '../../common/agentHostSchema.js'; import { createUnknownAgentHostClientTelemetryContext, type IAgentHostClientTelemetryContext, type IAgentProviderSendStageRecorder } from '../../common/agentHostTelemetry.js'; -import { AgentSession, AgentSignal, AgentWorkingDirectoryChangedError, AuthenticateParams, IMcpNotification, subagentChatTitle, type AgentSubagentTaskModelSource, type AgentTurnProviderCallState, type IAgentPendingMessageSender, type IAgentPermissionResponseContext, type IAgentTelemetryContext, type IAgentToolPendingConfirmationSignal, type IAgentTurnDiagnosticSnapshot, type IAgentTurnTokenUsage } from '../../common/agent.js'; +import { AgentSession, COPILOT_CLI_AGENT_PROVIDER_ID, AgentSignal, AgentWorkingDirectoryChangedError, AuthenticateParams, IMcpNotification, subagentChatTitle, type AgentSubagentTaskModelSource, type AgentTurnProviderCallState, type IAgentPendingMessageSender, type IAgentPermissionResponseContext, type IAgentTelemetryContext, type IAgentToolPendingConfirmationSignal, type IAgentTurnDiagnosticSnapshot, type IAgentTurnTokenUsage } from '../../common/agent.js'; import { isReasoningEffortLevel } from '../../common/reasoningEffort.js'; import { agentModelConfigurationMetaKey, IAgentRuntimeModelConfiguration, readAgentRuntimeModelConfiguration } from '../../common/meta/agentModelConfigurationMeta.js'; import { ObservedTokenUsage } from './observedTokenUsage.js'; @@ -90,7 +90,9 @@ import { CopilotSandboxDiagnostics } from './copilotSandboxDiagnostics.js'; import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; import { AGENT_MERGE_GITHUB_TOOL_RESTRICTION, getAgentMergeGitHubToolRestriction, isAgentMergeRestrictedMcpServer, isCopilotMcpToolName } from '../shared/agentMergeToolRestrictions.js'; import { GITHUB_MCP_SERVER_NAME } from '../shared/githubMcpServer.js'; -import { getEditFilePaths, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellIntention, getShellLanguage, getStreamingInvocationMessage, getSubagentMetadata, getTaskCompleteMarkdown, getToolDisplayName, getToolInputString, getToolKind, isAgentCoordinationTool, isCopilotSdkToolOutputFile, isEditTool, isHiddenTool, isShellHelperTool, isShellTool, isTaskCompleteTool, parseCopilotStreamingToolInput, synthesizeSkillToolCall, tryStringify } from './copilotToolDisplay.js'; +import { AgentHostGitHubMcpServerEnabledSettingId } from '../../common/agentService.js'; +import { CopilotToolName, getEditFilePaths, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getSdkImageGenerationMetadata, getShellIntention, getShellLanguage, getStreamingInvocationMessage, getSubagentMetadata, getTaskCompleteMarkdown, getToolDisplayName, getToolInputString, getToolKind, isAgentCoordinationTool, isCopilotSdkToolOutputFile, isEditTool, isHiddenTool, isShellHelperTool, isShellTool, isTaskCompleteTool, parseCopilotStreamingToolInput, synthesizeSkillToolCall, tryStringify } from './copilotToolDisplay.js'; +import { imageGenerationToolMetaKey } from '../../common/meta/agentImageGenerationMeta.js'; import { FileEditTracker } from '../shared/fileEditTracker.js'; import { ICopilotApiService, type IRestrictedTelemetryContext } from '../shared/copilotApiService.js'; import type { IAgentHostRestrictedTelemetryContext } from '../agentHostRestrictedTelemetry.js'; @@ -489,6 +491,7 @@ async function isPathWithinDirectory(filePath: string, directory: URI, resolveRe * Options for constructing a {@link CopilotAgentSession}. */ export interface ICopilotAgentSessionOptions { + readonly onSessionEvent?: (event: SessionEvent) => void; readonly sessionUri: URI; readonly chatChannelUri: URI; /** Exact persistence/config scope for this chat (`IAgentChatContext.resource` when supplied). */ @@ -788,6 +791,7 @@ class CopilotTurn extends Disposable { readonly clientContext: IAgentHostClientTelemetryContext, readonly telemetryContext: IAgentTelemetryContext | undefined, readonly providerInitiated = false, + private readonly _onWillDispose?: (turn: CopilotTurn) => void, ) { super(); // Most turns are never waited on; avoid an uncaught rejection. @@ -842,6 +846,7 @@ class CopilotTurn extends Disposable { * Rejects {@link eventId} before disposal so pending fork-boundary checks do not hang. */ override dispose(): void { + this._onWillDispose?.(this); this.sdkTurnIds.clear(); this.interactionIds.clear(); this.activeSdkTurnId = undefined; @@ -1043,6 +1048,8 @@ export class CopilotAgentSession extends Disposable { * replacing or clearing it disposes the old turn. */ private readonly _currentTurn = this._register(new MutableDisposable()); + /** Interaction ids of ended host turns; bounded, oldest first. See {@link _emitModelCallFinished}. */ + private readonly _endedTurnInteractionIds = new Set(); private readonly _idleWaiters = new Set>(); private readonly _completedTokenUsage = new Map(); private readonly _subagentObservedTokenUsage = new LRUCache(256); @@ -1312,6 +1319,7 @@ export class CopilotAgentSession extends Disposable { */ private readonly _shellInitScriptInstanceId = generateUuid().substring(0, 8); private readonly _launchPlan: CopilotSessionLaunchPlan; + private readonly _onSessionEvent: ((event: SessionEvent) => void) | undefined; private readonly _canvasByInstanceId = new Map(); private readonly _ignoredRestoredCanvasInstanceIds = new Set(); private _canvasProjectionReady = false; @@ -1449,6 +1457,7 @@ export class CopilotAgentSession extends Disposable { this._onDidSessionProgress = options.onDidSessionProgress; this._sessionLauncher = options.sessionLauncher; this._launchPlan = options.launchPlan; + this._onSessionEvent = options.onSessionEvent; this._sandboxDiagnostics = this._register(this._instantiationService.createInstance(CopilotSandboxDiagnostics, this._ownerSessionUri.toString(), () => this._launchPlan.client.rpc.sandbox.getHostSupport())); this._detectInterruptedTurnOnRestore = options.launchPlan.kind === 'resume'; this._onTurnEnded = options.onTurnEnded ?? (() => { }); @@ -1499,6 +1508,7 @@ export class CopilotAgentSession extends Disposable { FileEditTracker, this._storageUri.toString(), this._databaseRef.object, + COPILOT_CLI_AGENT_PROVIDER_ID, ); const pluginMcpServerSources = new Map((options.clientSnapshot?.plugins ?? []).flatMap(plugin => { @@ -1506,6 +1516,7 @@ export class CopilotAgentSession extends Disposable { return sourceUri === undefined ? [] : plugin.mcpServers.map(server => [server.name, sourceUri.toString()] as const); })); this._mcpCustomizations = this._register(this._instantiationService.createInstance(McpCustomizationController, { + provider: COPILOT_CLI_AGENT_PROVIDER_ID, chatUri: this._chatChannelUri, emit: action => this._emitAction(action), pluginMcpServerSources: () => pluginMcpServerSources, @@ -1513,6 +1524,11 @@ export class CopilotAgentSession extends Disposable { const resolution = this._customizationEnablementService.resolve(this._ownerSessionUri.toString(), targetForMcpServer(server, owningPluginUri, false)); return resolution.kind === 'resolved' ? resolution.enablement : undefined; }, + // The runtime adds its own GitHub server only while the setting is on; a server merely named the same + // (user, workspace or plugin configured) is not the runtime's, and the setting cannot control it. + controllingSetting: (serverName, provenance) => serverName === GITHUB_MCP_SERVER_NAME && provenance.source === 'builtin' && !provenance.sourcePlugin + ? AgentHostGitHubMcpServerEnabledSettingId + : undefined, })); this._register(toDisposable(() => this._cancelAllPendingInteractions())); @@ -1589,6 +1605,19 @@ export class CopilotAgentSession extends Disposable { }); } + private _retireTurnInteractionIds(turn: CopilotTurn): void { + for (const interactionId of turn.interactionIds) { + this._endedTurnInteractionIds.delete(interactionId); + this._endedTurnInteractionIds.add(interactionId); + } + for (const interactionId of this._endedTurnInteractionIds) { + if (this._endedTurnInteractionIds.size <= 256) { + break; + } + this._endedTurnInteractionIds.delete(interactionId); + } + } + private _emitModelCallFinished(event: ICopilotModelCallFinishedEvent): void { const parentToolCallId = this._parentToolCallIdForSubagentEvent(event); if (event.agentId && !parentToolCallId) { @@ -1604,6 +1633,15 @@ export class CopilotAgentSession extends Disposable { } else { turnId = turn?.sdkTurnIds.has(event.data.turnId) ? turn.id : undefined; } + // Each HydraFusion phase runs its own SDK turn under a fresh interaction id, but the runtime + // stages the phase's `assistant.turn_start` until commit and never delivers it for a discarded + // or cancelled phase. Attribute an unmapped call to the turn running the phase, unless its + // interaction belongs to an ended turn (a late call must not be adopted by its replacement). + const interactionId = event.data.interactionId; + if (!turnId && turn && !event.agentId && interactionId && !this._endedTurnInteractionIds.has(interactionId) && this._fusionProgress.runningPhaseToolCallId !== undefined) { + turnId = turn.id; + turn.interactionIds.add(interactionId); + } if (!turnId) { this._logService.trace(`[Copilot:${this.sessionId}] Ignoring model.call_finished without a host turn mapping: sdkTurnId=${event.data.turnId}`); return; @@ -2312,7 +2350,7 @@ export class CopilotAgentSession extends Disposable { this._detectInterruptedTurnOnRestore = false; this._streamingToolCalls.clear(); this._streamingToolDisplaySchedulers.clearAndDisposeAll(); - this._currentTurn.value = new CopilotTurn(turnId, this._nextTurnOrdinal++, senderClientId, clientContext, this._getTelemetryContext(), providerInitiated); + this._currentTurn.value = new CopilotTurn(turnId, this._nextTurnOrdinal++, senderClientId, clientContext, this._getTelemetryContext(), providerInitiated, turn => this._retireTurnInteractionIds(turn)); } async hasRunningDetachedShells(): Promise { @@ -2480,7 +2518,7 @@ export class CopilotAgentSession extends Disposable { void this._telemetryReporter.toolCallDetails({ clientContext: turn.clientContext, telemetryContext: turn.telemetryContext, - provider: this._ownerSessionUri.scheme, + provider: COPILOT_CLI_AGENT_PROVIDER_ID, session: this.resourceUri.toString(), chat: this._chatChannelUri.toString(), turnId: turn.id, @@ -2508,7 +2546,7 @@ export class CopilotAgentSession extends Disposable { this._telemetryReporter.toolApproval({ clientContext: this._currentTurn.value?.clientContext, telemetryContext: this._currentTurn.value?.telemetryContext, - provider: this._ownerSessionUri.scheme, + provider: COPILOT_CLI_AGENT_PROVIDER_ID, session: this.resourceUri.toString(), turnId: this._turnId, toolId: toolName, @@ -2980,8 +3018,13 @@ export class CopilotAgentSession extends Disposable { description: def.description ?? '', parameters: def.inputSchema ?? { type: 'object' as const, properties: {} }, defer: def.deferLoading && this._isToolSearchActive() ? 'auto' as const : 'never' as const, - handler: async (args: Record): Promise => { + handler: async (args: Record, invocation: ToolInvocation): Promise => { try { + // Native SDK workers inherit tools but invocation payloads omit their agent identity. + const toolCall = this._activeToolCalls.get(invocation.toolCallId); + if (def.topLevelChatOnly && (!toolCall || toolCall.parentToolCallId || this._unroutableSubagentToolCallIds.has(invocation.toolCallId))) { + throw new Error('This tool can only be called from a top-level chat with a known root tool-call origin.'); + } const text = host.executeTool(this._chatChannelUri.toString(), def.name, args); return { textResultForLlm: await text, resultType: 'success' }; } catch (error) { @@ -3119,6 +3162,17 @@ export class CopilotAgentSession extends Disposable { this._serverToolHost?.advertise(this._storageUri.toString()); } + /** Persists the host-owned catalog title through the runtime's naming API. */ + async synchronizeTitle(title: string): Promise { + const name = Array.from(title.trim()).slice(0, 100).join(''); + if (name && !this._store.isDisposed) { + const completed = await raceTimeout(this._wrapper.session.rpc.name.set({ name }).then(() => true), this._controlPlaneRpcTimeoutMs); + if (!completed) { + throw new Error('Mission Control title synchronization timed out'); + } + } + } + private async _waitForCanvasExtensions(wrapper: CopilotSessionWrapper): Promise { if (!wrapper.canvasRuntimeEnabled) { return; @@ -3208,6 +3262,11 @@ export class CopilotAgentSession extends Disposable { private _createRuntimeAdapter(): ICopilotSessionRuntime { return { + onSessionEvent: event => { + if (!this._store.isDisposed) { + this._onSessionEvent?.(event); + } + }, chatUri: this._chatChannelUri, configurationResource: this._ownerSessionUri, handlePermissionRequest: this._guarded(request => this._handlePermissionRequest(request), attributePermissionResult({ kind: 'reject' }, 'unattended_fallback'), 'permission'), @@ -6804,6 +6863,10 @@ export class CopilotAgentSession extends Disposable { } const displayName = tracked.displayName; const toolOutput = e.data.error?.message ?? e.data.result?.content; + const imageGeneration = tracked.toolName === CopilotToolName.ImageGeneration ? getSdkImageGenerationMetadata(e.data.result) : undefined; + if (imageGeneration) { + tracked.meta = { ...tracked.meta, [imageGenerationToolMetaKey]: imageGeneration }; + } if (isTaskCompleteTool(tracked.toolName)) { const summary = getTaskCompleteMarkdown(tracked.parameters, toolOutput); @@ -6897,7 +6960,7 @@ export class CopilotAgentSession extends Disposable { toolCallId: e.data.toolCallId, result: { success: e.data.success, - pastTenseMessage: getPastTenseMessage(tracked.toolName, displayName, tracked.parameters, e.data.success, e.data.success ? toolOutput : undefined, path => this._resolveEditFilePath(path), this._resolveAgentName), + pastTenseMessage: getPastTenseMessage(tracked.toolName, displayName, tracked.parameters, e.data.success, e.data.success ? toolOutput : undefined, path => this._resolveEditFilePath(path), this._resolveAgentName, tracked.meta?.[imageGenerationToolMetaKey]), content: content.length > 0 ? content : undefined, error: e.data.error, }, @@ -7379,6 +7442,16 @@ export class CopilotAgentSession extends Disposable { // subagent call counts toward the turn under its own model without // being counted twice by the parent and subagent emits below. turn?.addTokenTotals(eventContext.model, eventContext); + // The wrapper drops provisional HydraFusion `assistant.message` copies, so only the committed + // phase's replayed messages reach `onMessage`; discarded, cancelled, and review phases never + // report their model calls there. Each phase call still bills an attributed `assistant.usage` + // carrying the same `apiCallId`, so the turn's call set dedupes the committed replay. The + // Fusion run must belong to this host turn, so a late call from an ended turn is not adopted. + const fusion = e.data.fusion; + if (turn && !e.agentId && !parentToolCallId && fusion?.phaseId && e.data.apiCallId && e.data.interactionType !== 'conversation-compaction' + && this._fusionEventTurnIds.get(`fusion:${fusion.fusionId}`) === turn.id) { + this._emitModelCallCompleted(turn.id, e.data.apiCallId); + } const directUsage = isUnmappedSubagent ? undefined : this._directUsageFor(parentToolCallId, true); directUsage?.add(eventContext.model, eventContext, copilotUsage?.totalNanoAiu); const isChildUsage = e.data.interactionType === 'conversation-subagent' || e.data.initiator === 'sub-agent'; @@ -7948,12 +8021,14 @@ export class CopilotAgentSession extends Disposable { */ private _toSdkMcpServer(server: McpServer): ISdkMcpServer { const hasPendingAuthentication = this._hasPendingMcpAuthentication(server.name); - const source = server.source !== undefined + // Only inventory records report a source, so only they replace (or clear) provenance; lifecycle updates keep it. + const provenance = server.source !== undefined ? { source: server.source, sourceUri: server.source === 'user' ? URI.file(getCopilotMcpConfigurationPath(this._environmentService.userHome.fsPath, process.env)).toString() : null, + pluginName: server.sourcePlugin ?? null, } : {}; return { @@ -7962,8 +8037,7 @@ export class CopilotAgentSession extends Disposable { state: this._translateSdkMcpStatus(server.name, server.status, server.error, hasPendingAuthentication), ...(server.status === 'pending' && !hasPendingAuthentication ? { allowAuthRequiredToStarting: true } : {}), enabled: server.status !== 'disabled' && server.status !== 'not_configured', - ...source, - pluginName: server.sourcePlugin, + ...provenance, pluginVersion: server.sourcePluginVersion, }; } @@ -8224,7 +8298,7 @@ export class CopilotAgentSession extends Disposable { }; this._telemetryService.publicLog2('agentHost.instructionsCollected', { ...toInitiatorTelemetry(clientContext), - provider: this._ownerSessionUri.scheme, + provider: COPILOT_CLI_AGENT_PROVIDER_ID, agentSessionId: AgentSession.id(this.resourceUri), isSubagentSession: isSubagentSession(this.resourceUri), totalInstructionsCount: sources.length, @@ -8546,6 +8620,10 @@ export class CopilotAgentSession extends Disposable { this._register(wrapper.onFusionEvent(event => this._acceptFusionEvent(event))); this._register(wrapper.onUnhandledEvent(e => { + if (e.type === 'session.binary_asset') { + this._logService.trace(`[Copilot:${sessionId}] Binary asset received: ${e.data.mimeType}, ${e.data.byteLength} bytes`); + return; + } // Fusion handoffs/internal events can contain model-visible prompts, // including event types not yet represented in the SDK union. const loggedEvent = e.type.startsWith('session.fusion_') || e.type.startsWith('assistant.fusion_') @@ -8873,6 +8951,27 @@ export class CopilotAgentSession extends Disposable { this._register(wrapper.onToolProgress(e => { this._logService.trace(`[Copilot:${sessionId}] Tool progress: ${e.data.toolCallId} - ${e.data.progressMessage}`); + const tracked = this._activeToolCalls.get(e.data.toolCallId); + if (tracked?.toolName !== CopilotToolName.ImageGeneration) { + return; + } + const imageGeneration = getSdkImageGenerationMetadata(e.data); + if (!imageGeneration) { + return; + } + const parentToolCallId = tracked.parentToolCallId ?? this._parentToolCallIdForSubagentEvent(e); + if (!parentToolCallId && e.agentId) { + this._logService.warn(`[Copilot:${sessionId}] Dropping image progress for unknown subagent agentId=${e.agentId}`); + return; + } + tracked.meta = { ...tracked.meta, progressMessage: e.data.progressMessage, [imageGenerationToolMetaKey]: imageGeneration }; + this._emitAction({ + type: ActionType.ChatToolCallContentChanged, + turnId: tracked.turnId, + toolCallId: e.data.toolCallId, + content: tracked.content, + _meta: toToolCallMeta(tracked.meta), + }, parentToolCallId); })); this._register(wrapper.onSkillInvoked(e => { diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index 580f3a85fdcb35..a1b35fffb03fd5 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { AttributedPermissionResult, ContextTier, CopilotClient, ElicitationContext, ElicitationResult, ExitPlanModeRequest, ExitPlanModeResult, ModelCapabilitiesOverride, NamedProviderConfig, PermissionRequest, PermissionRequestResult, ProviderModelConfig, ResumeSessionConfig, SessionConfig, SessionHooks, Tool, Verbosity } from '@github/copilot-sdk'; +import type { AttributedPermissionResult, ConnectorStatus, ContextTier, CopilotClient, ElicitationContext, ElicitationResult, ExitPlanModeRequest, ExitPlanModeResult, ModelCapabilitiesOverride, NamedProviderConfig, PermissionRequest, PermissionRequestResult, ProviderModelConfig, ResumeSessionConfig, SessionConfig, SessionEvent, SessionHooks, Tool, Verbosity } from '@github/copilot-sdk'; import { coalesce } from '../../../../base/common/arrays.js'; import { Schemas } from '../../../../base/common/network.js'; import { equals as objectsEqual } from '../../../../base/common/objects.js'; @@ -208,6 +208,7 @@ export function toSdkToolFilterPatterns(patterns: readonly string[] | undefined) } export interface ICopilotSessionRuntime { + readonly onSessionEvent?: (event: SessionEvent) => void; /** Chat channel that owns this session's turns, used to attribute terminal claims. */ readonly chatUri: URI; /** Opaque scope shared by chats whose session configuration is shared. */ @@ -616,6 +617,9 @@ export async function synthesizeByokSessionConfig( ...(m.maxContextWindowTokens !== undefined ? { maxContextWindowTokens: m.maxContextWindowTokens } : {}), ...(m.maxPromptTokens !== undefined ? { maxPromptTokens: m.maxPromptTokens } : {}), ...(m.maxOutputTokens !== undefined ? { maxOutputTokens: m.maxOutputTokens } : {}), + // Without this the runtime treats the model as text-only and replaces + // tool-result images (e.g. from the `view` tool) with a text receipt. + ...(m.supportsVision !== undefined ? { capabilities: { supports: { vision: m.supportsVision } } } : {}), })); return { providers, models }; } @@ -815,6 +819,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { if (this._configurationService.getRootValue(platformRootSchema, AgentHostMcpConnectorsEnabledConfigKey) !== true) { return displayNames; } + let status: ConnectorStatus; try { const capabilities = await session.rpc.connectors.getCapabilities(); if (capabilities.availability !== 'enabled') { @@ -828,7 +833,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { if (!account) { const sessionHost = auth.host?.replace(/^https?:\/\//, '').replace(/\/+$/, '').toLowerCase(); const matchingAccounts = accounts.filter(candidate => { - const login = candidate.authInfo.type === 'env' || candidate.authInfo.type === 'user' || candidate.authInfo.type === 'gh-cli' + const login = candidate.authInfo.type === 'env' || candidate.authInfo.type === 'user' || candidate.authInfo.type === 'gh-cli' || candidate.authInfo.type === 'account' ? candidate.authInfo.login : candidate.authInfo.copilotUser?.login; const accountHost = candidate.authInfo.host.replace(/^https?:\/\//, '').replace(/\/+$/, '').toLowerCase(); @@ -841,14 +846,20 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { this._logService.warn(`[Copilot:${plan.sessionId}] Connector MCP reconciliation skipped because the session account could not be resolved`); return displayNames; } - const status = await session.rpc.connectors.reconcile({ accountId: account.selectionId, refreshCatalog: true }); - const connectorDisplayNames = new Map(status.catalog?.connectors.map(connector => [connector.name, connector.displayName.trim() || connector.name])); - for (const server of status.runtimeServers) { - displayNames.set(server.runtimeServerId, connectorDisplayNames.get(server.connectorName) ?? server.connectorName); - } + status = await session.rpc.connectors.reconcile({ accountId: account.selectionId, refreshCatalog: true }); this._logService.info(`[Copilot:${plan.sessionId}] Reconciled ${status.runtimeServers.length} connector MCP server(s) through the Copilot runtime`); } catch (error) { - this._logService.warn(`[Copilot:${plan.sessionId}] Connector MCP reconciliation failed; continuing without connector tools: ${getErrorMessage(error)}`); + this._logService.warn(`[Copilot:${plan.sessionId}] Connector MCP reconciliation failed; using the current Connector state when available: ${getErrorMessage(error)}`); + try { + status = await session.rpc.connectors.getStatus(); + } catch (statusError) { + this._logService.warn(`[Copilot:${plan.sessionId}] Unable to read the current Connector state after reconciliation failed: ${getErrorMessage(statusError)}`); + return displayNames; + } + } + const connectorDisplayNames = new Map(status.catalog?.connectors.map(connector => [connector.name, connector.displayName.trim() || connector.name])); + for (const server of status.runtimeServers) { + displayNames.set(server.runtimeServerId, connectorDisplayNames.get(server.connectorName) ?? server.connectorName); } return displayNames; } @@ -1099,6 +1110,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { ...byok, ...disabledMcpServers, onEvent: event => { + runtime.onSessionEvent?.(event); const owner = runtime.configurationResource.toString(); if (event.type === 'session.managed_settings_resolved' && !event.agentId) { this._configurationService.setSessionSandboxPolicy(owner, projectCopilotSandboxPolicy(event.data, plan.sessionId, this._logService)); diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionMetadataEvents.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionMetadataEvents.ts new file mode 100644 index 00000000000000..56147c06a31b84 --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionMetadataEvents.ts @@ -0,0 +1,144 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { open } from 'fs/promises'; +import { CancellationError } from '../../../../base/common/errors.js'; +import type { CancellationToken } from '../../../../base/common/cancellation.js'; +import type { URI } from '../../../../base/common/uri.js'; +import { isObject } from '../../../../base/common/types.js'; +import type { IAgentChatSessionEvent } from '../../common/agent.js'; + +const metadataEventTypes = new Set([ + 'session.start', 'session.resume', 'session.title_changed', 'session.idle', + 'assistant.turn_start', 'assistant.turn_end', + 'permission.requested', 'permission.completed', 'tool.user_requested', + 'user_input.requested', 'user_input.completed', + 'elicitation.requested', 'elicitation.completed', + 'exit_plan_mode.requested', 'exit_plan_mode.completed', + 'abort', +]); +const maxLineBytes = 1024 * 1024; + +/** Only root-agent catalog metadata is admitted; content, auth, configuration and unknown events are excluded. */ +export function toCopilotSessionMetadataEvent(chat: URI, event: unknown): IAgentChatSessionEvent | undefined { + if (!isObject(event)) { + return undefined; + } + const fields = event as Partial<{ type: string; agentId: string; id: string; timestamp: string; data: object; ephemeral: boolean }>; + if (typeof fields.type !== 'string' || !metadataEventTypes.has(fields.type) || fields.agentId !== undefined + || typeof fields.id !== 'string' || !fields.id + || typeof fields.timestamp !== 'string' || !Number.isFinite(Date.parse(fields.timestamp)) + || !isObject(fields.data)) { + return undefined; + } + return { chat, id: fields.id, timestamp: fields.timestamp, persisted: fields.ephemeral !== true, type: fields.type, data: fields.data }; +} + +/** Reconciles the latest genuine title/state facts after the cursor, with bounded memory and explicit unreadable-record loss. */ +export async function* readCopilotSessionMetadataEvents(chat: URI, path: string, token: CancellationToken, afterEventId?: string, onDidReadEventId?: (id: string) => void): AsyncIterable { + const file = await open(path, 'r'); + try { + const { size, mtimeMs } = await file.stat(); + if (!size) { + return; + } + const stream = file.createReadStream({ end: size - 1, highWaterMark: 64 * 1024, autoClose: false }); + const cancellation = token.onCancellationRequested(() => stream.destroy(new CancellationError())); + let pending = Buffer.alloc(0); + let oversized = false; + let position = 0; + let foundCursor = afterEventId === undefined; + let title: { readonly position: number; readonly event: IAgentChatSessionEvent } | undefined; + let state: { readonly position: number; readonly event: IAgentChatSessionEvent } | undefined; + let lost = 0; + let lastLostPosition = 0; + try { + for await (const chunk of stream) { + if (token.isCancellationRequested) { + throw new CancellationError(); + } + if (!Buffer.isBuffer(chunk)) { + throw new Error('Invalid Copilot journal chunk'); + } + let start = 0; + while (start < chunk.length) { + const newline = chunk.indexOf(10, start); + const end = newline === -1 ? chunk.length : newline; + const bytes = chunk.subarray(start, end); + if (!oversized) { + if (pending.length + bytes.length > maxLineBytes) { + pending = Buffer.alloc(0); + oversized = true; + } else { + pending = Buffer.concat([pending, bytes]); + } + } + position += end - start + (newline === -1 ? 0 : 1); + start = end + 1; + if (newline === -1) { + continue; + } + let event: IAgentChatSessionEvent | undefined; + let unreadable = oversized; + if (!oversized) { + try { + event = toCopilotSessionMetadataEvent(chat, JSON.parse(pending.toString('utf8'))); + } catch (error) { + if (!(error instanceof SyntaxError)) { throw error; } + unreadable = true; + } + } + pending = Buffer.alloc(0); + oversized = false; + if (event) { + onDidReadEventId?.(event.id); + } + const lossId = `journal-gap:${position}`; + if (!foundCursor) { + if (event?.id === afterEventId || unreadable && lossId === afterEventId) { + foundCursor = true; + yield event ?? { chat, id: lossId, timestamp: new Date(mtimeMs).toISOString(), persisted: true, type: 'session.events_truncated', data: { dropped_count: 1 } }; + } + continue; + } + if (afterEventId !== undefined) { + if (unreadable) { + yield { chat, id: lossId, timestamp: new Date(mtimeMs).toISOString(), persisted: true, type: 'session.events_truncated', data: { dropped_count: 1 } }; + } else if (event) { + yield event; + } + continue; + } + if (unreadable) { + lost++; + lastLostPosition = position; + } else if (event?.type === 'session.title_changed') { + title = { position, event }; + } else if (event) { + state = { position, event }; + } + } + } + } finally { + cancellation.dispose(); + stream.destroy(); + } + const reconciled = [title, state].filter(value => value !== undefined); + if (lost) { + reconciled.push({ + position: lastLostPosition, + event: { + chat, id: `journal-gap:${lastLostPosition}`, timestamp: new Date(mtimeMs).toISOString(), + persisted: true, type: 'session.events_truncated', data: { dropped_count: lost } + }, + }); + } + for (const item of reconciled.sort((a, b) => a.position - b.position)) { + yield item.event; + } + } finally { + await file.close(); + } +} diff --git a/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts b/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts index 90901db9f6436d..af06faa12887d8 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts @@ -29,6 +29,7 @@ const PLUGIN_MARKETPLACE_COMPLETION_CACHE_TTL_MS = 30_000; * retrieve runtime slash command metadata and apply feature gating. */ export interface ICopilotSlashCommandSessionInfo { + ownsSession?(session: string): boolean; /** * Whether the experimental rubber duck critic subagent is enabled via * the agent host config. When provided and `false`, `/rubber-duck` is hidden. @@ -76,7 +77,7 @@ export class CopilotSlashCommandCompletionProvider implements IAgentHostCompleti ) { } async provideCompletionItems(params: CompletionsParams, _token: CancellationToken): Promise { - if (AgentSession.provider(params.channel) !== this.copilotcliId) { + if (!(this._sessionInfo.ownsSession?.(params.channel) ?? (AgentSession.provider(params.channel) === this.copilotcliId))) { return []; } const sessionId = AgentSession.id(params.channel); diff --git a/src/vs/platform/agentHost/node/copilot/copilotTodoStoreTelemetry.ts b/src/vs/platform/agentHost/node/copilot/copilotTodoStoreTelemetry.ts index 264d7e7a835faf..5fd9c305963356 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotTodoStoreTelemetry.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotTodoStoreTelemetry.ts @@ -5,7 +5,7 @@ import { URI } from '../../../../base/common/uri.js'; import type { ITelemetryService } from '../../../telemetry/common/telemetry.js'; -import { AgentSession, type AgentSubagentKind } from '../../common/agent.js'; +import { AgentSession, COPILOT_CLI_AGENT_PROVIDER_ID, type AgentSubagentKind } from '../../common/agent.js'; import type { IAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; import { isSubagentSession } from '../../common/state/sessionState.js'; import { toInitiatorTelemetry, toSubagentKindTelemetry, type IAgentHostEventClassification, type IAgentHostEventTelemetry, type IAgentHostSubagentKindClassification } from '../agentHostTelemetryReporter.js'; @@ -55,7 +55,7 @@ export function reportCopilotTodoStoreOperation(telemetryService: ITelemetryServ ...toInitiatorTelemetry(clientContext), ...operation, toolCallId, - provider: session.scheme, + provider: COPILOT_CLI_AGENT_PROVIDER_ID, agentSessionId: AgentSession.id(session), isSubagentSession: isSubagent, ...toSubagentKindTelemetry(isSubagent, subagentKind), diff --git a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts index 3d4a4b28ed02f3..b0bcfae4328729 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts @@ -12,6 +12,7 @@ import { isAbsolute } from '../../../../base/common/path.js'; import { localize } from '../../../../nls.js'; import type { IAgentToolPendingConfirmationSignal } from '../../common/agent.js'; import type { ToolKind } from '../../common/meta/agentToolCallMeta.js'; +import { parseImageGenerationToolMetadata, type IImageGenerationToolMetadata } from '../../common/meta/agentImageGenerationMeta.js'; import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; import { parsePartialToolInput } from '../../common/partialToolInput.js'; import { StringOrMarkdown } from '../../common/state/protocol/state.js'; @@ -70,6 +71,7 @@ export const enum CopilotToolName { GitApplyPatch = 'git_apply_patch', WebSearch = 'web_search', WebFetch = 'web_fetch', + ImageGeneration = 'image_generation', AskUser = 'ask_user', ReportIntent = 'report_intent', Think = 'think', @@ -624,6 +626,7 @@ export function getToolDisplayName(toolName: string, metadata?: Pick | undefined, success: boolean, resultText?: string, resolvePath: ToolPathResolver = identityPathResolver, resolveAgentName?: ToolAgentNameResolver): StringOrMarkdown { +export function getSdkImageGenerationMetadata(data: unknown): IImageGenerationToolMetadata | undefined { + if (!isObject(data)) { + return undefined; + } + const structuredContent = (data as Record)['structuredContent']; + return isObject(structuredContent) + ? parseImageGenerationToolMetadata((structuredContent as Record)['imageGeneration']) + : undefined; +} + +export function getPastTenseMessage(toolName: string, displayName: string, parameters: Record | undefined, success: boolean, resultText?: string, resolvePath: ToolPathResolver = identityPathResolver, resolveAgentName?: ToolAgentNameResolver, imageGeneration?: IImageGenerationToolMetadata): StringOrMarkdown { if (!success) { return localize('toolComplete.failed', "\"{0}\" failed", displayName); } @@ -905,6 +920,10 @@ export function getPastTenseMessage(toolName: string, displayName: string, param } switch (toolName) { + case CopilotToolName.ImageGeneration: + return imageGeneration + ? localize('toolComplete.imageGenerationModel', "Generated image with {0}", imageGeneration.requestedModel.name ?? imageGeneration.requestedModel.id) + : localize('toolComplete.imageGeneration', "Generated image"); case CopilotToolName.WebFetch: { const args = parameters as ICopilotWebFetchToolArgs | undefined; if (args?.url) { diff --git a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts index 939f1605aa9546..a92266a3c64f6e 100644 --- a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts +++ b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts @@ -10,13 +10,14 @@ import { basename, isAbsolute, join } from '../../../../base/common/path.js'; import { isString } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; -import { AgentSession } from '../../common/agent.js'; +import { AgentSession, COPILOT_CLI_AGENT_PROVIDER_ID } from '../../common/agent.js'; import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; import { readToolCallMeta, toToolCallMeta, type IToolCallUiMeta, type ToolKind } from '../../common/meta/agentToolCallMeta.js'; import { IFileEditRecord, ISessionDatabase } from '../../common/sessionDataService.js'; import { MessageAttachmentKind, type MessageAttachment } from '../../common/state/protocol/state.js'; import { createErrorResponsePart, MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, parseChatUri, type AgentSelection, type ErrorInfo, type Message, type ModelSelection, type ResponsePart, type StringOrMarkdown, type TerminalCommandResult, type ToolCallCompletedState, type ToolResultContent, type ToolResultTerminalContent, type Turn, type UsageInfo } from '../../common/state/sessionState.js'; -import { getInvocationMessage, getPastTenseMessage, getShellIntention, getShellLanguage, getSubagentMetadata, getTaskCompleteMarkdown, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, isTaskCompleteTool, synthesizeSkillToolCall, type ToolAgentNameResolver } from './copilotToolDisplay.js'; +import { CopilotToolName, getInvocationMessage, getPastTenseMessage, getSdkImageGenerationMetadata, getShellIntention, getShellLanguage, getSubagentMetadata, getTaskCompleteMarkdown, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, isTaskCompleteTool, synthesizeSkillToolCall, type ToolAgentNameResolver } from './copilotToolDisplay.js'; +import { imageGenerationToolMetaKey } from '../../common/meta/agentImageGenerationMeta.js'; import { buildSessionDbUri } from '../../common/sessionDbUri.js'; import { getMediaMime } from '../../../../base/common/mime.js'; import { buildCopilotSystemNotification, getCopilotSubagentDisplayNames } from './copilotSystemNotification.js'; @@ -430,7 +431,7 @@ export async function mapSessionEvents( const sessionUriStr = session.toString(); const routingSession = URI.parse(routingChat.session); - const providerId = routingSession.scheme; + const providerId = AgentSession.provider(routingSession) ?? COPILOT_CLI_AGENT_PROVIDER_ID; const rawSessionId = AgentSession.id(routingSession); const turns: Turn[] = []; @@ -830,7 +831,7 @@ export async function mapSessionEvents( // No active turn to attach this completion to. continue; } - const completedPart = makeCompletedToolCallPart(d, info, sessionUriStr, providerId, rawSessionId, routingChatUri, storedEdits, subagentInfoByToolCallId.get(d.toolCallId), workingDirectory, resolveAgentName); + const completedPart = makeCompletedToolCallPart(d, info, sessionUriStr, routingSession, providerId, rawSessionId, routingChatUri, storedEdits, subagentInfoByToolCallId.get(d.toolCallId), workingDirectory, resolveAgentName); builder.responseParts.push(completedPart); // When a parent tool call that spawned a subagent completes, // flush the subagent's accumulated turn. @@ -934,6 +935,7 @@ export async function mapSessionEvents( completion ?? { toolCallId: request.toolCallId, success: true }, info, sessionUriStr, + routingSession, providerId, rawSessionId, routingChatUri, @@ -1065,6 +1067,7 @@ function makeCompletedToolCallPart( d: ToolExecutionCompleteData, info: IToolStartInfo, sessionUriStr: string, + routingSession: URI, providerId: string, rawSessionId: string, chatURI: URI, @@ -1073,6 +1076,7 @@ function makeCompletedToolCallPart( workingDirectory: URI | undefined, resolveAgentName: ToolAgentNameResolver, ): ResponsePart { + const imageGeneration = info.toolName === CopilotToolName.ImageGeneration ? getSdkImageGenerationMetadata(d.result) : undefined; const toolOutput = d.error?.message ?? d.result?.content; const content: ToolResultContent[] = []; if (toolOutput !== undefined) { @@ -1083,7 +1087,7 @@ function makeCompletedToolCallPart( d.result?.contents, info.toolKind === 'terminal' ? { storage: sessionUriStr, - session: AgentSession.uri(providerId, rawSessionId), + session: routingSession, chat: chatURI, toolCallId: d.toolCallId, title: info.displayName, @@ -1133,7 +1137,7 @@ function makeCompletedToolCallPart( const mcpUi: IToolCallUiMeta | undefined = mcpUiResourceUri ? { resourceUri: mcpUiResourceUri, - ...(mcpServerName ? { channel: buildMcpChannel(chatURI, mcpServerName) } : {}), + ...(mcpServerName ? { channel: buildMcpChannel(chatURI, mcpServerName, providerId) } : {}), } : undefined; @@ -1147,11 +1151,12 @@ function makeCompletedToolCallPart( invocationMessage: info.invocationMessage, toolInput: info.toolInput, success: d.success, - pastTenseMessage: getPastTenseMessage(info.toolName, info.displayName, info.parameters, d.success, d.success ? toolOutput : undefined, path => resolveToolDisplayPath(path, workingDirectory), resolveAgentName), + pastTenseMessage: getPastTenseMessage(info.toolName, info.displayName, info.parameters, d.success, d.success ? toolOutput : undefined, path => resolveToolDisplayPath(path, workingDirectory), resolveAgentName, imageGeneration), content: content.length > 0 ? content : undefined, error: d.error, confirmed: ToolCallConfirmationReason.NotNeeded, _meta: toToolCallMeta({ + [imageGenerationToolMetaKey]: imageGeneration, toolKind: info.toolKind, language: info.language, subagentDescription: info.subagentDescription, diff --git a/src/vs/platform/agentHost/node/copilot/pendingEditContentStore.ts b/src/vs/platform/agentHost/node/copilot/pendingEditContentStore.ts index 08888a2d4bec49..b5e25bf690431a 100644 --- a/src/vs/platform/agentHost/node/copilot/pendingEditContentStore.ts +++ b/src/vs/platform/agentHost/node/copilot/pendingEditContentStore.ts @@ -3,32 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { encodeHex, VSBuffer } from '../../../../base/common/buffer.js'; import { IDisposable } from '../../../../base/common/lifecycle.js'; -import { URI } from '../../../../base/common/uri.js'; import { IFileService } from '../../../files/common/files.js'; import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; +import { PENDING_EDIT_CONTENT_SCHEME } from '../../common/pendingEditContentUri.js'; -/** - * URI scheme for transient file content backing tool-call write-permission - * previews. Files under this scheme live in an in-memory provider registered - * on the agent host's file service; content can be read/written through the - * file service just like any other resource. - */ -export const PENDING_EDIT_CONTENT_SCHEME = 'pending-edit-content'; - -/** - * Builds a `pending-edit-content:` URI identifying the proposed "after" - * content for a write permission request. The authority is a hex-encoded - * session URI so multiple concurrent sessions don't collide. - */ -export function buildPendingEditContentUri(sessionUri: string, toolCallId: string, filePath: string): URI { - return URI.from({ - scheme: PENDING_EDIT_CONTENT_SCHEME, - authority: encodeHex(VSBuffer.fromString(sessionUri)).toString(), - path: `/${encodeURIComponent(toolCallId)}/${encodeHex(VSBuffer.fromString(filePath))}`, - }); -} +export { buildPendingEditContentUri, PENDING_EDIT_CONTENT_SCHEME } from '../../common/pendingEditContentUri.js'; /** * Registers a fresh {@link InMemoryFileSystemProvider} for the @@ -45,4 +25,3 @@ export function registerPendingEditContentProvider(fileService: IFileService): I }, }; } - diff --git a/src/vs/platform/agentHost/node/devContainerAgentHostService.ts b/src/vs/platform/agentHost/node/devContainerAgentHostService.ts index 6c7095ddb9c52c..42eeda4efdafdb 100644 --- a/src/vs/platform/agentHost/node/devContainerAgentHostService.ts +++ b/src/vs/platform/agentHost/node/devContainerAgentHostService.ts @@ -27,7 +27,8 @@ import { IProductService } from '../../product/common/productService.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { INativeEnvironmentService } from '../../environment/common/environment.js'; -import { asTextOrError, IRequestService } from '../../request/common/request.js'; +import { IRequestService } from '../../request/common/request.js'; +import { IGitHubService } from '../../github/common/githubService.js'; import { getResolvedShellEnv } from '../../shell/node/shellEnv.js'; import { IDevContainerAgentHostConfig, IDevContainerAgentHostConnectResult, IDevContainerAgentHostMainService, VSCODE_REMOTE_CONTAINERS_SESSION_ENV } from '../common/devContainerAgentHost.js'; import { IRelayMessage } from '../common/relayTransport.js'; @@ -49,7 +50,7 @@ import { import { ensureRemoteAgentHostCliInstalled } from './remoteAgentHostCliInstaller.js'; import { prepareOwnerOnlyDirectory } from './localAgentHostMetadata.js'; import { buildCreateDevContainerCacheCommand, buildLinkDevContainerServerCacheCommand, canAddDevContainerServerCacheMount, devContainerServerCacheMount, getDevContainerCliCachePath, getDevContainerServerCachePath } from './devContainerServerCache.js'; -import { DevContainerSample, devContainerSamples, devContainerSampleUri, IDevContainerRepository, IDevContainerSampleSource } from '../common/devContainerSamples.js'; +import { DevContainerSample, devContainerSamples, devContainerSampleUri, getDevContainerSampleFolder, IDevContainerRepository, IDevContainerSampleSource } from '../common/devContainerSamples.js'; import { IPreparedDevContainerSample, prepareDevContainerSample } from './devContainerSamples.js'; const LOG_PREFIX = '[DevContainerAgentHost]'; @@ -210,6 +211,7 @@ export abstract class DevContainerAgentHostService extends Disposable implements @ITelemetryService private readonly _telemetryService: ITelemetryService, @INativeEnvironmentService private readonly _environmentService: INativeEnvironmentService, @IRequestService private readonly _requestService: IRequestService, + @IGitHubService private readonly _gitHubService: IGitHubService, ) { super(); } @@ -419,13 +421,17 @@ export abstract class DevContainerAgentHostService extends Disposable implements return result; }, devcontainer: args => this._runDevContainer(connectionId, args, token), - fetch: async url => { - const response = await this._requestService.request({ type: 'GET', url, callSite: 'devContainerSample', headers: { 'User-Agent': 'VSCode' } }, token); - const content = await asTextOrError(response); - if (!content) { - throw new Error(localize('devContainerAgentHost.emptySampleResponse', "Empty response while downloading the Dev Container sample: {0}", url)); + readSource: async () => { + const lifetime = new DisposableStore(); + try { + const client = lifetime.add(this._gitHubService.acquireAnonymousClient()).object; + return await client.getFile('microsoft', getDevContainerSampleFolder(sample), '.devcontainer/devcontainer.json', token, { + caller: 'devContainerSample', + priority: 'interactive', + }); + } finally { + lifetime.dispose(); } - return content; }, }); } @@ -1036,8 +1042,9 @@ export class DevContainerAgentHostMainService extends DevContainerAgentHostServi @IConfigurationService private readonly _configurationService: IConfigurationService, @INativeEnvironmentService private readonly _nativeEnvironmentService: INativeEnvironmentService, @IRequestService requestService: IRequestService, + @IGitHubService gitHubService: IGitHubService, ) { - super(_mainLogService, productService, telemetryService, _nativeEnvironmentService, requestService); + super(_mainLogService, productService, telemetryService, _nativeEnvironmentService, requestService, gitHubService); } protected override _resolveUserShellEnvironment(): Promise { diff --git a/src/vs/platform/agentHost/node/devContainerSamples.ts b/src/vs/platform/agentHost/node/devContainerSamples.ts index 24de309803cafc..4272314564ea34 100644 --- a/src/vs/platform/agentHost/node/devContainerSamples.ts +++ b/src/vs/platform/agentHost/node/devContainerSamples.ts @@ -8,6 +8,7 @@ import { readFile, rename, rm, writeFile } from 'fs/promises'; import { ParseError, parse } from '../../../base/common/json.js'; import { join } from '../../../base/common/path.js'; import { localize } from '../../../nls.js'; +import { IGitHubRepositoryFile } from '../../github/common/anonymousClient.js'; import { DevContainerSample, getDevContainerSampleFolder, getDevContainerSampleUrl, IDevContainerRepository } from '../common/devContainerSamples.js'; import { prepareOwnerOnlyDirectory } from './localAgentHostMetadata.js'; import { shellEscape } from './sshRemoteAgentHostHelpers.js'; @@ -22,7 +23,7 @@ interface ICommandResult { interface ISampleCommands { readonly docker: (args: readonly string[]) => Promise; readonly devcontainer: (args: readonly string[]) => Promise; - readonly fetch: (url: string) => Promise; + readonly readSource: () => Promise; readonly onContainerStarted: (containerId: string) => void; } @@ -105,13 +106,9 @@ export async function prepareDevContainerSample(sample: DevContainerSample, cach } const folder = getDevContainerSampleFolder(sample); if (sourceContent === undefined) { - const commit: unknown = JSON.parse(await commands.fetch(`https://api.github.com/repos/microsoft/${folder}/commits/HEAD`)); - if (!isRecord(commit) || typeof commit.sha !== 'string' || !/^[a-f0-9]{40}$/.test(commit.sha)) { - throw new Error(localize('devContainerSample.invalidCommit', "GitHub returned an invalid sample revision.")); - } - const content = await commands.fetch(`https://raw.githubusercontent.com/microsoft/${folder}/${commit.sha}/.devcontainer/devcontainer.json`); + const { commitSha, content } = await commands.readSource(); parseDevContainerSampleConfiguration(content); - sourceContent = JSON.stringify({ commit: commit.sha, content }); + sourceContent = JSON.stringify({ commit: commitSha, content }); await writeAtomic(sourcePath, sourceContent); } const source: unknown = JSON.parse(sourceContent); diff --git a/src/vs/platform/agentHost/node/missionControl/MISSION_CONTROL.md b/src/vs/platform/agentHost/node/missionControl/MISSION_CONTROL.md new file mode 100644 index 00000000000000..d20d5936a98678 --- /dev/null +++ b/src/vs/platform/agentHost/node/missionControl/MISSION_CONTROL.md @@ -0,0 +1,78 @@ +# Mission Control environments + +Mission Control exposes VS Code's existing native Agent Host utility process as a user-local environment over Azure Web PubSub. Local sessions continue using local IPC; remote clients use the same authoritative session state and protocol handler, not a separate daemon. + +```text +Local VS Code ------ local IPC ------+ + +--- one native Agent Host and session state +Other clients ----- MC / WPS --------+ +``` + +Registration is opt-in and default-off. The activation path is available to built desktop products as well as source builds; unsupported host variants do not acquire it merely because an AHP client requests a connection. The feature remains tagged experimental. [Readiness and remaining validation](./MISSION_CONTROL_PRODUCTION_GAPS.md) must not be confused with a guarantee about every release artifact or deployed client. + +## Enable the real service + +1. Sign in to GitHub in the owning VS Code window. +2. Leave `chat.agentHost.experimentalMissionControlFakeEndpoint` empty. +3. Enable `chat.agentHost.experimentalMissionControl.enabled`. +4. Keep the default `chat.agentHost.experimentalMissionControl.endpoint` (`https://api.github.com`) unless an explicitly trusted environment requires a different HTTPS API origin. The local registration credential is sent to this origin; redirects and credential-bearing/unsafe origin shapes are refused. +5. Wait for `Mission Control ready; environmentId=...` in the Agent Host log. +6. Create/use native sessions normally. From another compatible MC client, discover the environment, connect, authenticate with a sealed credential, and use standard AHP session operations. + +An empty window can register the host without advertising a home-directory default. Disable the registration setting to withdraw remote access. Withdrawal disconnects relay ingress immediately; it does not delete native sessions, conversations or checkouts. + +### The local test-endpoint setting + +`chat.agentHost.experimentalMissionControlFakeEndpoint` selects a **loopback HTTP integration-test server**, not Azure or a production MC server. It is retained for deterministic registration/relay tests. The local GitHub credential is sent to that endpoint, so use only a test server you trust. It must be empty for real-service registration; enabling real MC while it is set is refused. Ordinary users do not need to configure it. + +The existing setting keys are retained so current opt-in continues working. Classes and local management methods use ordinary Mission Control names; the setting keys and experimental feature tags are not a second runtime or a build restriction. + +## Environment identity, name and ownership + +The registered name is `VS Code`, `VS Code Insiders`, or `VS Code OSS`; other product variants use their product short name. The same host-owned name is sent on registration and every heartbeat. This display label is not identity. + +A random stable compute UUID is persisted in `agent-host-mission-control-id` under the user-data directory. It is not derived from hardware. The versioned record retains its canonical directory only locally, so a migrated profile copied to another directory receives a different identity without changing the original. Legacy plain UUIDs migrate in place; copies made before migration remain an explicit edge case. Restarting the same profile reuses its identity. Separate user-data directories intentionally represent separate hosts and may have identical display names. + +The first accepted account owns the shared utility process until restart. Same-owner windows may refresh the credential and contribute canonical workspace roots, without rebinding the active registration's default directory. Foreign-account configuration/withdrawal is refused. Genuine withdrawal clears the active grants/default; a later same-owner registration establishes new ones. Identity-authority changes withdraw access rather than silently rebinding the host. + +## Discover and connect + +Enable `chat.remoteAgentHostsEnabled` and invoke **Connect to Mission Control Environment...** from the Command Palette or Agents Window workspace-management menu. The picker lists your account's existing user-local environments, shows cached results immediately, and refreshes in the background without clearing the search. It excludes the native host's own environment so the owning window stays on local IPC. + +Selection revalidates availability. An offline user-local host is not woken or replaced; start its owning application before connecting. Discovery itself does not provision compute or create sessions. Connected user-local hosts use the generic native Sessions provider, not the managed-sandbox task/history/checkout adapter. The fuller retained-host/management UX is a separate increment. + +New native allocations negotiate standard `ahp-session` resources with a separate provider. Existing legacy and standard session/chat resources, SDK backings, and storage remain immutable. The client consumes exact advertised resources; MC does not impose a global Copilot alias. See the [identity contract](../../../../sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md#identity). + +## Security and data handling + +The [security requirements matrix](./MISSION_CONTROL_SECURITY_REQUIREMENTS.md) links each requirement to implementation and tests. The supported boundary includes canonical-owner sealed authentication, signed control requests, lane/client-ID binding, passive mutation refusal, locally known workspace/resource grants, minimal pre-authentication state, and host-wide root-configuration exclusion. Remote windows cannot overwrite the owning machine's root settings or managed permissions. + +Remote owner access includes native sessions, tools, and granted workspace resources. Credentials are purpose-separated sealed-box values before WPS publication; recipient keys come from authenticated MC HTTPS rather than being trusted solely because they appeared in the relay. Private sealing keys stay host-local. This does not provide per-client/session credential sponsorship, OS confinement, or conversation end-to-end encryption. + +Native authoritative AHP actions and selected genuine SDK metadata are mirrored to MC for history and task title/activity. Approval/input metadata may contain commands, diffs, plans and questions. The SDK adapter excludes assistant/system messages, auth/configuration, subagents, per-token deltas and routine backing shutdown events; AHP mirroring still carries authoritative conversation content. + +`chat.agentHost.experimentalMissionControl.requireConnectionBinding` defaults to false for compatibility with deployed pre-sealed tokens. Supplied bindings are verified; unbound envelopes remain replayable. Optional MCP resource context and the shared native provider credential store have the limitations described in the matrix. Do not infer stronger guarantees from successful transport authentication. + +The SDK's canonical device `remoteControl` branch is read before registration and forwarded to MC for enforcement. Failed initial reads do not register as unrestricted. The host does not introduce a second enterprise-policy parser or claim local enforcement of every predicate against a compromised MC signer. + +## Lifecycle and mirroring + +The host uses proxy-aware bounded HTTP, a normal heartbeat cadence, and service-requested `Retry-After` waits. Recovery does not bypass those waits; disabling closes ingress even when its offline heartbeat must be delayed. WPS bootstrap/key refresh and relay keep-alive are independent of ordinary inbound traffic. + +Host-wide diagnostic log channels are not advertised on Mission Control ingress. Diagnostic logs remain available locally; they do not share the relay's bounded publisher with session operations. This does not change provider-native OTel export. + +A single ordered publisher and bounded reassembly/queue/lane limits preserve live protocol ordering. Replacement connections have distinct generations; stale predecessor frames/closures are fenced. Request-form compatibility for `dispatchAction` and `unsubscribe` shares the native notification path and does not make arbitrary notifications successful requests. + +Independent AHP/SDK spools use durable ingest acknowledgements and bounded failure/truncation signals. Signed backfill replays retained AHP frames exactly. SDK sequence ranges are reserved durably before publication; native journal cursors advance only after durable SDK acknowledgement. AHP process-restart epochs/spool durability and complete pre-registration history remain deferred. Native titles are synchronized through the runtime naming API, not fabricated SDK events. + +## Implementation ownership + +Source files live alongside these documents. [MissionControlHost](./missionControlHost.ts) is an entry-owned DI adapter for native services, handler lifetime and authoritative mirror wiring. [MissionControlEnvironment](./missionControlEnvironment.ts) owns registration/recovery through a named host-options contract; it has no second runtime service graph. + +Registration and heartbeat request construction are separate methods. Local management IPC is distinct from AHP; host-management extension methods remain unavailable to relay clients. The same `ProtocolServerHandler` owns session operations on both local and relay transports. + +## Validation boundaries + +Tests cover built-product adapter construction without pre-opt-in traffic, product names, registration/heartbeat consistency, owner/lane/passive/resource guards, sealed vectors, stale generations, bounded mirrors and recovery. Current release artifact/network/client evidence and the explicitly agreed deferrals are listed in [remaining work](./MISSION_CONTROL_PRODUCTION_GAPS.md). Qualification of an earlier artifact is not evidence for a later revision. + +(Written by Copilot) diff --git a/src/vs/platform/agentHost/node/missionControl/MISSION_CONTROL_PRODUCTION_GAPS.md b/src/vs/platform/agentHost/node/missionControl/MISSION_CONTROL_PRODUCTION_GAPS.md new file mode 100644 index 00000000000000..b3cd206b5f154e --- /dev/null +++ b/src/vs/platform/agentHost/node/missionControl/MISSION_CONTROL_PRODUCTION_GAPS.md @@ -0,0 +1,50 @@ +# Mission Control remaining gaps + +Only outstanding work and intentionally deferred limitations belong here. [Security requirement coverage](./MISSION_CONTROL_SECURITY_REQUIREMENTS.md) provides the implementation and test references; [operation and configuration](./MISSION_CONTROL.md) describes usage. + +## Validation and release gaps + +| Remaining gap | Completion criterion | Help needed from the maintainer | +| --- | --- | --- | +| Final-head packaged qualification and remaining platforms | Qualify signed-product registration, remote discovery/execution and consent withdrawal on the final code revision and intended Windows/Linux packages. | None immediately. Use isolated authenticated profiles with Settings Sync explicitly disabled; never launch a copied authenticated profile with sync enabled. | +| Extended relay reliability and load qualification | Correlate spontaneous transport closures; qualify longer idle/active runs, simultaneous-client pressure, load shedding and prolonged outages against the real service. | No immediate action. Flag a specific service or sign-in blocker when encountered. | +| Current-head CI | Complete the relevant build/test/security workflows on the current PR head before merge. | None immediately. The prerequisite has merged and this PR now targets `main`; qualify the updated head using the automatic PR workflows. Preserve normal review requirements. | + +No code-design decision from the maintainer is currently blocking these items. Missing deployment evidence must not be replaced with fake-backed success. + +## Security-contract gaps + +| Remaining gap | Current limitation | Help needed from the maintainer | +| --- | --- | --- | +| Authoritative credential expiry and live authorization lapse | GitHub's expiration header is not consumed; authenticated lanes do not lose observation/steering/MCP authorization at that deadline, and no integrated targeted lapse notification exists. Caller-provided expiry and relay-ticket renewal are not substitutes. | Scope decision: whether this is required for the continuing-development merge or explicitly deferred with the shared credential work. Security S04. | +| Durable per-session mirror opt-out | Sharing is enabled for the entire host; there is no persisted per-session choice suppressing AHP/SDK/lifecycle publication after cold resume. | Product decision: whether host-wide consent is sufficient for this increment or per-session opt-out must precede merge. Security S30. | +| Enterprise endpoint coherence and disclosure | MC independently defaults to dotcom rather than the configured GitHub identity origin; deliberate cross-host content residency and identity provenance are not explicitly surfaced. | Scope decision on enterprise support for this increment; supported topology/endpoint access is needed for deployment validation. Security S15. | +| Independent host-local policy and quotas | Fixed transport bounds do not enforce operator-owned session/repository restrictions or configurable active-session/spawn quotas. Reporting device policy to MC does not provide independent enforcement against a compromised MC signer. | Applicability decision for this user-local increment. Security S17/S28. | + +## Deferred validation + +These qualification items are not part of the current continuing-development merge decision. + +| Remaining gap | Completion criterion | Help needed when resumed | +| --- | --- | --- | +| Enterprise proxy/custom-certificate deployment | Verify HTTP, identity validation, registration and recovery in the intended enterprise/network configuration. | Access to the target test environment and its supported endpoint configuration. | +| Copilot app compatibility and disconnect diagnosis | Verify a protocol-compatible app and establish unexpected disconnect/process-exit causes from correlated client/host evidence. The known AHP 0.9/host 0.10 mismatch must not be concealed by a permanent protocol downgrade. | A compatible Copilot app build for the client-specific exercise. | +| Production privacy/release approval | Confirm opt-in disclosure, owner access, MC conversation storage/retention and service-side handling are approved for the intended audience. | The responsible product/privacy/security review or its existing approval. | + +## Environment-management UX + +First-class discovery and management alongside SSH/dev tunnels remains in the next stacked PR: retained host inventory before connection, offline-host management, refresh, reconnect, local naming, and hide/restore behavior. + +## Accepted deferrals + +These limitations remain intentionally outside this increment rather than prerequisites for completing the agreed scope. + +| Deferred gap | Current limitation | Reference | +| --- | --- | --- | +| Mandatory connection binding | Default mode accepts unbound sealed tokens, which remain replayable. Omitted MCP resource context can permit retargeting within a purpose. | [Authentication](./missionControlAuthentication.ts), security S08. | +| Credential sponsorship | Credentials are shared by resource/scopes rather than pinned to client/generation/session drivers; lineage-aware sign-out, lease expiry and driver handoff are not enforced. Ordinary transport disconnect alone need not revoke a valid session lease. | [Native store](../agentHostAuthenticationService.ts), security S01/S03/S09. | +| OS containment/general native hardening | No claim of OS confinement, atomic filesystem isolation, or comprehensive native provider/logging/export credential isolation. Runtime secret-filter readiness and catch-up are not established. | [Relay authorization](../protocolServerHandler.ts), security S09/S21. | +| Durable AHP restart/history baseline | AHP stream epochs/counters/spools and a complete pre-registration history baseline are not durable across process restart. | [Mirror](./missionControlSessionMirror.ts), [SDK source](./missionControlSdkEventSource.ts), security S31. | +| Response-part ID continuity | Reconstructed Copilot markdown/reasoning IDs can differ from live IDs, causing a client to reject a recovery snapshot as omitting displayed content. | [History mapping](../copilot/mapSessionEvents.ts), security S33. | + +(Written by Copilot) diff --git a/src/vs/platform/agentHost/node/missionControl/MISSION_CONTROL_SECURITY_REQUIREMENTS.md b/src/vs/platform/agentHost/node/missionControl/MISSION_CONTROL_SECURITY_REQUIREMENTS.md new file mode 100644 index 00000000000000..561c59452de61f --- /dev/null +++ b/src/vs/platform/agentHost/node/missionControl/MISSION_CONTROL_SECURITY_REQUIREMENTS.md @@ -0,0 +1,88 @@ +# Mission Control security and contract requirements + +Requirement sources, VS Code enforcement and verification. See [remaining gaps](https://github.com/microsoft/vscode/blob/roblou%2Fagents%2Ftypescript-vscode-agent-host-prototype/src/vs/platform/agentHost/node/missionControl/MISSION_CONTROL_PRODUCTION_GAPS.md) and [operation and configuration](https://github.com/microsoft/vscode/blob/roblou%2Fagents%2Ftypescript-vscode-agent-host-prototype/src/vs/platform/agentHost/node/missionControl/MISSION_CONTROL.md). + +## Requirement sources + +Requirements are grouped by topic, not by implementation status. Sources include the [Copilot Host security invariants](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/AGENTS.md?plain=1#L44-L54), its architecture documents and protocol specifications, VS Code contracts, and user choices. Assistant proposals and design choices are labeled separately. + +Copilot Host file/line citations refer to revision `db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518`. Copilot Host links require repository access. Protocol specifications define wire compatibility, not the implementation status of either host. Drafts and recommended defaults are labeled. The [token model](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/token-model.md?plain=1#L1-L8) describes target behavior, not an inventory of upstream implementation. VS Code implementation and test links are pinned to revision `0622f8dd27e63028aee8f5cc94131dd7650fafe9`; companion documentation follows the PR branch. + +## 1. Identity and credential authority + +| ID / requirement | Requirement source | VS Code implementation / gap | Evidence and boundary | +| --- | --- | --- | --- | +| S01. GitHub is the identity authority: match the canonical environment owner; MCP cannot establish it. | **Copilot Host architecture:** [canonical owner and direct GitHub validation, token-model.md:32-66](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/token-model.md?plain=1#L32-L66). **Protocol specification:** [separate identity/MCP purposes](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/spec/encryption/README.md?plain=1#L120-L127). | **Implemented for principal equality.** [GitHub identity validation/lane auth](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/missionControl/missionControlAuthentication.ts) requires a User matching the owner and fences late results after handshake, close or authority change. MCP waits for identity. | [Owner/foreign/non-user/stale/concurrent-MCP tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/missionControlAuthentication.test.ts). Equality does not establish equal scopes or sponsorship. | +| S02. Authorize steering and observation before session access; withhold host-wide secrets. | **Copilot Host architecture:** [identity authority/secret compartmentalization, token-model.md:32-66](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/token-model.md?plain=1#L32-L66); [live authorization, token-model.md:99-120](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/token-model.md?plain=1#L99-L120). **Assistant proposal:** project minimal pre-auth root state and strip host configuration even after auth. | **Implemented for lane admission/root projection.** [Requests, root snapshots and envelopes](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/protocolServerHandler.ts) gate session access and omit root config; pre-auth roots omit terminals/customizations. | [Identity/minimal-root tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts), [post-auth refresh tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts). Not blanket redaction of session/tool fields or deadline-based authorization lapse; see S04. | +| S03. New work follows its sponsor: credential slots, safe re-pin and lease invalidation. | **Copilot Host architecture:** [pin/re-pin/safe boundaries, token-model.md:190-221](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/token-model.md?plain=1#L190-L221); [disconnect versus sign-out, token-model.md:353-375](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/token-model.md?plain=1#L353-L375). ordinary transport disconnect does not require immediate revocation of an existing valid session lease. | **Not satisfied; accepted deferral.** The shared native credential store needs redesign. The [native token store](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/agentHostAuthenticationService.ts) is resource/scope-keyed, not client/generation/session-keyed; [relay authentication](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/protocolServerHandler.ts) uses that shared provider path. | Owner rebinding is refused, but no per-client scope isolation, driver credential pinning or lineage-aware sign-out/revocation guarantee exists. Lane closure ends lane authorization, not necessarily shared provider credential retention. | +| S04. Credential expiry: use the GitHub-reported deadline, revoke live authorization and request reauthentication. | **Copilot Host architecture:** [GitHub header, notification and point-of-use checks, token-model.md:99-120](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/token-model.md?plain=1#L99-L120). **Important source limitation:** [line 270](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/token-model.md?plain=1#L270) says upstream already reads GitHub's deadline, rejects new protected work after expiry and sends `auth/required`. Early renewal and ending existing subscriptions remain outstanding upstream. | **Not satisfied.** [Identity resolution](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/missionControl/missionControlAuthentication.ts) does not consume `GitHub-Authentication-Token-Expiration`; lane authorization persists until re-handshake/close/authority change. [Shared store](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/agentHostAuthenticationService.ts) uses caller-supplied `expiresIn`. | No integrated deadline checks across awaited work, observation, steering/MCP completion or exactly-once targeted `auth/required` on lapse. Distinct from sponsorship S03 and WPS ticket renewal S14. | +| S05. Keep the host control-plane credential separate from remote client authentication. | **Copilot Host architecture:** [four distinct credentials, token-model.md:23-31](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/token-model.md?plain=1#L23-L31); [operator-side rotation, mission-control.md:24-26](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/mission-control.md?plain=1#L24-L26). **Assistant proposal:** resolve the local credential in the trusted workbench and install it over dedicated management IPC, not from a remote authenticate request. | **Implemented.** The [workbench contribution](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHost.contribution.ts) resolves local sessions and uses the [management service](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/agentHostManagementService.ts). Lane authentication cannot configure registration. | [Owner/rebinding/withdrawal tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/missionControlProtocolServer.test.ts). Registration credentials remain in process memory while needed; no claim of eliminating all native provider copies. | + +## 2. Sealed authentication and credential handling + +| ID / requirement | Requirement source | VS Code implementation / gap | Evidence and boundary | +| --- | --- | --- | --- | +| S06. Require sealed GitHub/MCP authenticate values over Web PubSub; never downgrade to plaintext. | **Copilot Host architecture:** [relay threat model and sender/receiver obligations, e2e-encryption.md:3-32](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/e2e-encryption.md?plain=1#L3-L32). The no-plaintext/fail-closed relay boundary follows this contract. | **Implemented for native relay `authenticate`.** [Outbound guard](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/browser/webPubSubRelayTransport.ts) refuses non-sealed tokens before logging/framing; [host opening](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/missionControl/missionControlAuthentication.ts) requires the v1 envelope; [credential refresh](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxCredentialRefresh.ts) has no plaintext fallback. | [Plaintext/redaction tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/browser/webPubSubRelayTransport.test.ts), [sealed-auth tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/missionControlAuthentication.test.ts). This does not sanitize arbitrary secrets in ordinary messages/files/config. | +| S07. Host-advertised keys: authenticate the recipient, separate purposes and keep private keys host-local. | **Protocol specification:** [out-of-band host identity, encryption/README.md:189-205](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/spec/encryption/README.md?plain=1#L189-L205) and [purpose separation, lines 120-127](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/spec/encryption/README.md?plain=1#L120-L127). **Assistant proposal:** validate supported implementations with language-independent conformance test vectors. | **Implemented for the supported sealed-box path.** [Sealing](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/missionControl/missionControlAuthentication.ts) uses separate auth/MCP keypairs. [Native MCP key pinning](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/workbench/contrib/chat/browser/remoteAgentHost/missionControlCredentialSealing.ts) compares trusted HTTPS environment keys with live metadata; relay-only advertisement is insufficient. | [Independent vector](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/missionControlAuthentication.test.ts), [recipient trust tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/missionControlCredentialSealing.test.ts). Only NaCl sealed-box is advertised, not HPKE. MC can see a token when it performs broker resealing. | +| S08. Connection binding: make sealed authentication one-time per handshake generation. | **Protocol specification:** [challenge/nonce/age and transition mode, encryption/README.md:211-227](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/spec/encryption/README.md?plain=1#L211-L227). **Assistant proposal:** complete or safely gate this boundary before enabling a real endpoint. | **Partial.** Mandatory binding is an accepted deferral. [Per-handshake challenge and binding checks](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/missionControl/missionControlAuthentication.ts) verify any supplied binding. `requireConnectionBinding` defaults false for deployed pre-sealed clients. | [Binding/replay/transition tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/missionControlAuthentication.test.ts). Unbound envelopes remain replayable; omitted MCP resource context can allow retargeting within its purpose. Default mode is not compromised-relay replay protection. | +| S09. Keep credentials out of logs, disk and mirroring; protect them throughout their lifetime. | **Copilot Host invariant:** [no log/disk credential exposure and disposal cleanup, AGENTS.md:48](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/AGENTS.md?plain=1#L48). **Copilot Host architecture:** [memory-only/compartmentalized credentials, token-model.md:15-43](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/token-model.md?plain=1#L15-L43); [runtime filter ordering, secret-filter-ingress.md:65-81](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/secret-filter-ingress.md?plain=1#L65-L81). [Launcher ingress, lines 7-29](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/secret-filter-ingress.md?plain=1#L7-L29) is optional/deployment-specific, not a mandatory user-local interface. | **Partial.** [Transport redaction](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/browser/webPubSubRelayTransport.ts), [owned buffer/key scrubbing](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/missionControl/missionControlAuthentication.ts) and credential-free MC discovery metadata exist. | [Redaction](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/browser/webPubSubRelayTransport.test.ts), [inventory projection](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxApiService.test.ts). JS strings, provider retention/local exports and arbitrary secret-bearing outputs remain outside a universal redaction/zeroization guarantee. Runtime filter readiness/catch-up/poisoning is not established. | + +## 3. Environment registration and lifecycle + +| ID / requirement | Requirement source | VS Code implementation / gap | Evidence and boundary | +| --- | --- | --- | --- | +| S10. One environment, one owner: credential rotation must not rebind it. | **Copilot Host architecture:** [token-model.md:49-66](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/token-model.md?plain=1#L49-L66) fixes the canonical GitHub origin/user identity for the environment's lifetime and requires replacement credentials to resolve to that owner. | **Implemented with a stricter VS Code lifetime rule.** [Serialized configuration](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/missionControl/missionControlEnvironment.ts) makes the first accepted local account own the shared utility process until restart. Same-owner windows may refresh credentials/contribute roots; foreign configuration or withdrawal is refused. The process-lifetime rule is a VS Code design choice, not an additional requirement prescribed by the host document. | [Multi-window and owner tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/missionControlProtocolServer.test.ts). This is ownership, not per-client credential sponsorship. | +| S11. Persist random stable compute identity without hardware fingerprinting or credential-bearing discovery records. | **Assistant proposal:** random per-profile compute identity without hardware fingerprinting. **Assistant proposal:** credential-free discovery and distinguishing copied user-data directories. | **Implemented.** [Identity persistence](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/missionControl/missionControlEnvironment.ts) uses a UUID and atomic owner-only record; a migrated copy in a different canonical directory receives a new identity. Paths remain local. | [Restart/copy/legacy/alias tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/missionControlProtocolServer.test.ts). Copies of the pre-migration plain-UUID format remain indistinguishable; existing native single-writer lifecycle is assumed. | +| S12. Use proxy-aware HTTPS, safe credential destinations and no credential redirects; distinguish loopback fakes. | **Copilot Host architecture:** [safe MC origin/transport, mission-control.md:44](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/mission-control.md?plain=1#L44). **Assistant proposal:** reuse the native proxy-aware host fetch. **Assistant proposal:** redirect refusal and the stricter loopback-only fake boundary. | **Implemented for configured-origin trust, not an allowlist.** Proxy-aware fetch uses the [native proxy service](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/agentHostProxyResolver.ts). [Endpoint validation/requests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/missionControl/missionControlEnvironment.ts), [identity lookup](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/missionControl/missionControlAuthentication.ts) and [native fetch wiring](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/missionControl/missionControlHost.ts) reject redirects and unsafe shapes. | [Enterprise identity tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/missionControlAuthentication.test.ts), [authority-change tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/missionControlProtocolServer.test.ts). A locally configured custom HTTPS MC origin receives the bearer; HTTPS alone does not make it trusted. Enterprise proxy/certificate deployment remains unverified. | +| S13. Register accurate user-local identity/embedder information and consume MC bootstrap without Azure management credentials. | **Copilot Host architecture:** [environment/bootstrap, mission-control.md:8-16](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/mission-control.md?plain=1#L8-L16); [MC-owned JWT minting, lines 95-102](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/mission-control.md?plain=1#L95-L102). **Assistant proposal:** user-local registration with a VS Code embedder and no Azure resource provisioning. **User choice:** product display names. | **Implemented.** [Registration/response validation](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/missionControl/missionControlEnvironment.ts) uses stable `compute_id`, `kind: user-local`, `embedder: vscode` and returned WPS bootstrap. [Display names](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/missionControl/missionControlHost.ts) distinguish VS Code/Insiders/OSS. | [Registration and naming tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/missionControlProtocolServer.test.ts), [product construction tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/missionControlHost.test.ts). No Azure resource provisioning. | +| S14. Register, heartbeat, token refresh and reconnect without resurrecting disabled or refused access. | **Copilot Host architecture:** [registration/heartbeat/token/connect/reconnect, mission-control.md:47-58](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/mission-control.md?plain=1#L47-L58). **Protocol specification:** [reconnect layering, framing.md:235-257](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/spec/transport/webpubsub/framing.md?plain=1#L235-L257). **Assistant proposal:** exact backoff, timeout, stale-generation and permanent-refusal handling. **User choice:** extended reliability validation. | **Implemented with bounded live evidence.** [Host lifecycle](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/missionControl/missionControlEnvironment.ts), [client refresher](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxCredentialRefresh.ts) and [connection factory](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxAgentHostService.ts) renew bootstrap/credentials and fence stale work. | [Host lifecycle tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/missionControlProtocolServer.test.ts), [client renewal tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxAgentHostService.test.ts), [transport/client recovery](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts). Ticket renewal is not GitHub-authoritative authorization expiry. Longer real-service qualification remains incomplete. | +| S15. Endpoint reconciliation: preserve GitHub identity provenance and disclose cross-host content residency. | **Copilot Host architecture:** [resolved-host default, explicit override and warning, mission-control.md:82-93](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/mission-control.md?plain=1#L82-L93). It explicitly permits deliberate cross-host topology; a mandatory same-origin refusal would be a new policy, not the cited requirement. | **Partial.** [Identity endpoint service](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/agentHostGitHubEndpointService.ts) supplies GitHub authority and changes withdraw registration, but the [MC setting](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHost.contribution.ts) independently defaults to dotcom without a divergence warning. | [Enterprise identity](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/missionControlAuthentication.test.ts), [authority changes](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/missionControlProtocolServer.test.ts). No coherent-default/residency disclosure coverage. Equal numeric IDs across origins are not principal proof. | +| S16. Require local opt-in and withdraw on sign-out/consent/account changes; fence stale results. | **User choice:** a functional TypeScript/native integration available in built products, default-off and not ready for release. **Assistant proposal:** opt-in activation. **Copilot Host architecture:** [explicit sign-out, token-model.md:353-375](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/token-model.md?plain=1#L353-L375). **Assistant proposal:** stale-renderer fencing and AI-hidden withdrawal. | **Implemented host-wide opt-in.** [Workbench lifecycle](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHost.contribution.ts) withdraws on disable/AI hiding/owner session loss. [Host withdrawal](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/missionControl/missionControlEnvironment.ts) immediately disposes ingress/sealing material even if offline heartbeat is delayed. | [Disable/foreign-withdrawal tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/missionControlProtocolServer.test.ts), [account-switch tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxAgentHostService.test.ts). Closing one window is not sign-out/shutdown/deletion; this does not implement per-client lease revocation or per-session mirror consent. | +| S17. Report runtime remote-control policy to Mission Control only after a successful read. | **Copilot Host architecture:** [canonical device policy, unknown-state gate/re-registration, mission-control.md:72-80](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/mission-control.md?plain=1#L72-L80). **Copilot Host invariant:** [independent local policy survives MC compromise, AGENTS.md:51](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/AGENTS.md?plain=1#L51) and [session/repository restrictions, mission-control.md:108-129](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/mission-control.md?plain=1#L108-L129). These are separate from reporting the runtime device-policy branch. | **Policy reporting implemented.** [Runtime read](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/copilot/copilotAgent.ts) validates shape; [registration/check-in](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/missionControl/missionControlEnvironment.ts) forwards the canonical branch and detects changes without a second enterprise parser. MC evaluates that device branch. | [Policy-change tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/missionControlProtocolServer.test.ts). Independent host-local session-type/repository policy and configurable active-session/spawn limits are not implemented. MC-owned device policy and defense against a compromised signer must not be conflated. | + +## 4. Host permissions and resource access + +| ID / requirement | Requirement source | VS Code implementation / gap | Evidence and boundary | +| --- | --- | --- | --- | +| S18. Disable VS Code host-management extensions over WPS and advertise only usable capabilities. | **Copilot Host invariant:** [MC cannot mutate host configuration/privileges, AGENTS.md:49](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/AGENTS.md?plain=1#L49). **Assistant proposal:** disable VS Code-only management methods on relay ingress. **Copilot Host architecture:** [truthful capability advertisement, mission-control.md:132-134](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/mission-control.md?plain=1#L132-L134). | **Implemented.** [Relay configuration](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/missionControl/missionControlHost.ts) sets `allowExtensionMethods: false`; [server capability/method gates](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/protocolServerHandler.ts) retain standard session operations without native management claims. | [Capability and forbidden-extension tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts). No remote registration/configuration management surface. | +| S19. Remote windows cannot overwrite owner-machine root configuration or managed permissions. | **Copilot Host invariant:** [MC is a peer, not a configuration/privilege controller, AGENTS.md:49](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/AGENTS.md?plain=1#L49). **Assistant proposal:** forbid owner-machine root/managed-permission writes over this VS Code relay. This is not a general AHP rule that all hosts must prohibit all configuration writes. | **Implemented across settings/trust changes and reconnect.** [Client forwarding fences](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts) complement [server root-action rejection](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/protocolServerHandler.ts). | [Server root/permission tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts), [WPS configuration isolation](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts). Live owner/viewer configuration separation was checked. | +| S20. Passive connections: reject mutations and shared-provider credential replacement. | **Copilot Host architecture:** [signed passivity and connection-level mutation gate, permissions.md:455-491](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/permissions.md?plain=1#L455-L491). **Assistant design choice:** passive clients may authenticate their identity, but cannot replace shared native provider credentials. | **Implemented.** [RPC/action/authentication gates](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/protocolServerHandler.ts) fence mutations and shared provider authentication; UI passivity is not the security boundary. | [Passive host/request tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts), [remote read-only tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts). | +| S21. Authorize relay filesystem RPCs, terminal cwd and workspace mutations without implicit home/private-file grants. | **Assistant proposal:** replace local-only home/resource-grant assumptions with explicit relay workspace grants. **Copilot Host invariant and architecture:** [containment, AGENTS.md:52](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/AGENTS.md?plain=1#L52) and [separate authorization/permission/OS layers, token-model.md:17](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/token-model.md?plain=1#L17). Neither prescribes our exact RPC grant implementation. | **Implemented RPC grants; OS containment deferred.** [Grant ownership](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/missionControl/missionControlHost.ts) includes local/session workspaces and read-only session-content roots. [Checks](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/protocolServerHandler.ts) reject symlink escapes/dangling links, invalid ancestors/cwd, arbitrary pending-edit reads, non-file writes and option-shaped Git refs. | [Filesystem regressions](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts). Not atomic against link replacement, hard-link isolation, shell confinement or comprehensive non-filesystem authorization. Direct/local grants are unchanged. | + +## 5. Control-message verification + +| ID / requirement | Requirement source | VS Code implementation / gap | Evidence and boundary | +| --- | --- | --- | --- | +| S22. Verify signed control before acting. | **Copilot Host invariant:** [untrusted signed control, AGENTS.md:50](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/AGENTS.md?plain=1#L50). **Protocol specification:** [ES256/low-S/JWKS/strict enforcement, control-signing.md:74-111](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/spec/transport/webpubsub/control-signing.md?plain=1#L74-L111). | **Implemented for spawn/backfill.** [Verifier](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/missionControl/missionControlControl.ts) checks header/key shape and canonical low-S signatures. [Dispatch](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/missionControl/missionControlProtocolServer.ts) has no unsigned fallback. | [Forgery and valid-high-S rejection tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/missionControlProtocolServer.test.ts). Signing keys come from trusted MC HTTPS. This does not defend against the trusted signer itself. | +| S23. Check freshness, replay, environment/owner, kind and signed-versus-acted-on fields. | **Copilot Host invariant:** [signature/timestamp/nonce/environment-owner binding, AGENTS.md:50](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/AGENTS.md?plain=1#L50). **Protocol specification:** [verification algorithm, control-signing.md:94-105](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/spec/transport/webpubsub/control-signing.md?plain=1#L94-L105). **Assistant design choice:** 300-second skew and 1,024-entry ledger; the cited specification defaults to 60 seconds, not 300. | **Implemented with those local bounds.** [Claims/spawn/backfill validation](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/missionControl/missionControlControl.ts) ties outer fields to signed payload and checks namespace/range before dispatch. | [Mismatch/stale/replay/backfill tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/missionControlProtocolServer.test.ts). Replay state is process/registration-scoped, not durable across restart. | +| S24. Refresh/revoke signing keys without resetting live replay protection. | **Protocol specification:** [JWKS selection/rotation, control-signing.md:80-103](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/spec/transport/webpubsub/control-signing.md?plain=1#L80-L103). **Assistant proposal:** preserve the current nonce ledger during key refresh. | **Implemented within a registration.** [JWKS refresh](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/missionControl/missionControlEnvironment.ts) updates the existing [verifier](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/missionControl/missionControlControl.ts), rejecting private/duplicate/incompatible keys. | [Key-rotation regression](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/missionControlProtocolServer.test.ts). Fleet revocation latency and rollout are deployment concerns. | + +## 6. Relay lanes, ordering and limits + +| ID / requirement | Requirement source | VS Code implementation / gap | Evidence and boundary | +| --- | --- | --- | --- | +| S25. Validate WPS owner/environment groups and publisher identity; transport admission is not AHP identity. | **Protocol specification:** [JWT/client identity carriers, framing.md:202-223](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/spec/transport/webpubsub/framing.md?plain=1#L202-L223). **Copilot Host architecture:** [routing owner is not authorization, mission-control.md:12](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/mission-control.md?plain=1#L12). | **Implemented host-side.** [Ingress](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/missionControl/missionControlProtocolServer.ts) checks groups and live client `fromUserId`; control still requires signatures. | [Verified-lane tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/missionControlProtocolServer.test.ts), [client framing/group tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/browser/webPubSubRelayTransport.test.ts). MC/Azure own least-privilege ticket issuance; this host does not independently prove every role. | +| S26. Bind initialize/reconnect client ID to its verified lane and reject local connection collisions. | **Protocol specification:** [client identifier consistency, framing.md:202-223](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/spec/transport/webpubsub/framing.md?plain=1#L202-L223). **Assistant proposal:** bind initialize/reconnect to the WPS lane. **Assistant proposal:** reject collisions across native handlers. | **Implemented for live/remembered identities.** [Lane receive](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/missionControl/missionControlProtocolServer.ts) and [shared server admission](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/protocolServerHandler.ts) reject a different or colliding client ID. | [Lane binding and collision tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/missionControlProtocolServer.test.ts), [cross-handler tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts). No permanent reservation beyond history expiry/restart. | +| S27. Fence predecessor generations and duplicate/stale frames; preserve ordering and bounded waits. | **Protocol specification:** [single-in-flight ordering, framing.md:25-41](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/spec/transport/webpubsub/framing.md?plain=1#L25-L41); [connection generations/client rules, framing.md:278-311](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/spec/transport/webpubsub/framing.md?plain=1#L278-L311). **Assistant proposal:** concrete ACK/handshake timeout values. | **Implemented.** [Lane generations and ordered publisher](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/missionControl/missionControlProtocolServer.ts), [client transport](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/browser/webPubSubRelayTransport.ts), shared [framing](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/common/webPubSub/framing.ts) and [reassembly](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/common/webPubSub/chunking.ts) fence old connections and ACK receipt before delivery. | [Host ordering/re-handshake tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/missionControlProtocolServer.test.ts), [client generation/ACK/handshake tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/browser/webPubSubRelayTransport.test.ts). Ordering is not durable mutation idempotency after a lost reply. | +| S28. Limit relay traffic and enforce host-local policy and quotas. | **Protocol specification (recommended limits):** [reassembly bounds, chunking.md:194-205](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/spec/transport/webpubsub/chunking.md?plain=1#L194-L205). **Copilot Host invariant and architecture:** [independent host enforcement, AGENTS.md:51](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/AGENTS.md?plain=1#L51) and [active-session/spawn limits, mission-control.md:116-129](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/mission-control.md?plain=1#L116-L129). **Assistant design choice:** exact VS Code lane/request/queue/mirror caps, including 32 lanes and 128 queued mirror frames. | **Partial.** Fixed transport bounds are implemented; independent host-local policy and configurable quotas are not. [Publisher](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/missionControl/missionControlProtocolServer.ts) limits lanes to 32, early messages to 32, receives to 2 MiB and queues to 512 frames/64 MiB. Mirror admission pauses at 128 queued frames/16 MiB. [MC ingress](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/missionControl/missionControlHost.ts) omits host-wide diagnostic logs. | [Host pressure/lane tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/missionControlProtocolServer.test.ts), [mirror pause/overflow/reattachment tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/missionControlSessionMirror.test.ts), [diagnostic isolation](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/missionControlHost.test.ts). No configurable active-session quota or signed lane-opening rate limit is claimed. | + +## 7. Conversation sharing and mirroring + +| ID / requirement | Requirement source | VS Code implementation / gap | Evidence and boundary | +| --- | --- | --- | --- | +| S29. Sealed tokens are not a sealed content channel; disclose which content leaves the machine. | **Copilot Host architecture:** [token-only trust/directions, e2e-encryption.md:3-32](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/e2e-encryption.md?plain=1#L3-L32) and [future bulk-content channel, lines 343-364](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/e2e-encryption.md?plain=1#L343-L364). **Assistant proposal:** explicit VS Code opt-in disclosure and product/privacy/retention approval; these are not established by the encryption spec. | **Disclosure/transport behavior implemented; release approval unverified.** [Setting text](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHost.contribution.ts) describes MC storage/no content E2EE. [SDK allowlist](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/copilot/copilotSessionMetadataEvents.ts) narrows metadata; AHP mirroring carries conversations. | [Metadata filtering](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/copilotSessionMetadataEvents.test.ts), [disabled source](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/missionControlSdkEventSource.test.ts). Approval/input metadata can contain commands/diffs/plans/questions. MC/WPS see content; no full-stream E2EE or retention certification. | +| S30. Session mirror opt-out: remoteSession off must survive cold resume. | **Copilot Host architecture:** [off/export/on and cold-resume persistence, persistence.md:5-13](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/persistence.md?plain=1#L5-L13); [MC consent distinction, mission-control.md:104-106](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/mission-control.md?plain=1#L104-L106). | **Not satisfied.** [AHP mirror source](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/missionControl/missionControlHost.ts) and [SDK source](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/missionControl/missionControlSdkEventSource.ts) use host-wide enablement, not persisted per-session `remoteSession: off`. | Needed coverage: zero AHP/SDK/lifecycle publication after opt-out, including cold resume/chat/changeset events. Provider-native export settings do not suppress this mirror. Global-disabled tests are not per-session consent evidence. | +| S31. Session mirror: authoritative AHP, independent namespaces, bounded spools, ingest acknowledgements and backfill. | **Protocol specification (draft):** [namespace/loss/replay rules, session-mirror.md:11-40](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/spec/transport/webpubsub/session-mirror.md?plain=1#L11-L40) and [in-process durability limits, lines 7-9](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/spec/transport/webpubsub/session-mirror.md?plain=1#L7-L9). **Assistant proposal:** select the authoritative onDidEmitEnvelope source, rather than inventing parallel per-session writers. | **Partial.** In-process mirroring is implemented; full AHP restart continuity is an accepted deferral. [Source wiring](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/missionControl/missionControlHost.ts), [independent spools/ACK/backfill](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/missionControl/missionControlSessionMirror.ts) and [durable SDK sequence reservations/journal cursors](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/missionControl/missionControlSdkEventSource.ts) own publication. | [Mirror tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/missionControlSessionMirror.test.ts), [SDK restart/cursor tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/missionControlSdkEventSource.test.ts). AHP epochs/counters/spools and complete pre-registration history baselines are not durable. | + +## 8. Native integration, compatibility and deployment + +| ID / requirement | Requirement source | VS Code implementation / gap | Evidence and boundary | +| --- | --- | --- | --- | +| S32. Expose the existing native Agent Host, retaining local IPC and one authoritative session state. | **User choice:** integrate MC into the VS Code Agent Host and provide a functional TypeScript/native integration. **Assistant proposal:** reuse the existing shared native host, not a sidecar or parallel AHP server. | **Implemented.** [Utility-process wiring](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/missionControl/missionControlHost.ts) attaches WPS to the same `AgentService`/`AgentHostStateManager` through `ProtocolServerHandler`; no sidecar or parallel reducer. | [Native/WPS integration](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts), [empty-window registration](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/missionControlProtocolServer.test.ts). | +| S33. Preserve advertised session/chat/provider identity and distinguish attach, reconnect, restore and execution. | **VS Code contract:** [immutable backend/provider identity, provider specification:70-74](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md?plain=1#L70-L74). **Protocol specification:** [reconnect/generation behavior](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/spec/transport/webpubsub/framing.md?plain=1#L235-L257) and [lost-request uncertainty/client IDs, framing.md:278-311](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/spec/transport/webpubsub/framing.md?plain=1#L278-L311). **Assistant proposal:** qualify restored response-part continuity rather than infer it from transport recovery. | **Partial.** Advertised session URIs are preserved; response-part continuity and recovery of mutations with lost replies are not guaranteed. [Allocation negotiation](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/common/agentHostSessionIdentity.ts) preserves mixed immutable identities; no global MC provider URI alias. | [Identity](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/common/agentHostSessionIdentity.test.ts), [mixed server catalogs](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts), local/remote provider tests. Reconstructed response-part IDs can fail recovery-integrity checks; RPC IDs do not make lost create/send replies idempotent. | +| S34. Preserve VS Code distribution guarantees and qualify cryptography in supported packaged products. | **Copilot Host invariant:** [signed/notarized distribution and independent verified updates, AGENTS.md:54](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/AGENTS.md?plain=1#L54). **Assistant design choice:** VS Code-specific ASAR/native-platform checks; the source does not prescribe those exact tests. **User choice:** support built products and qualify a signed artifact. | **Partial.** Packaging and the named macOS arm64 artifact were verified; the final revision and other platforms remain unqualified. [Desktop](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/package.json)/[remote](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/remote/package.json) manifests and [dependency tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/build/lib/test/agentHostDependencies.test.ts) include libsodium. Existing signing/update machinery is unchanged. | [Build 480503](https://dev.azure.com/monacotools/Monaco/_build/results?buildId=480503) at `7c6725d9cf1` passed signature/notarization, ASAR-backed crypto/native work, remote discovery/session creation/model completion and verified shell output. Disabling opt-in in the signed workbench disposed relay ingress, preserved local AHP and produced MC offline status. Settings Sync was explicitly disabled and its log confirmed AutoSync disabled. This validation applies only to the named artifact, not later revisions; final-head packaging, Windows/Linux deployment and CI remain unverified. | +| S35. Do not import managed-compute principal, attestation, provisioning or isolation exceptions into user-local code. | **Copilot Host architecture:** [distinct environment kinds/routing principals, mission-control.md:8-16](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/docs/architecture/mission-control.md?plain=1#L8-L16). **Assistant design choice:** user-local VS Code hosting. **Assistant proposal:** refuse managed-principal shapes instead of importing unrelated managed-compute exceptions. | **Out of scope/refused.** [Registration](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/missionControl/missionControlEnvironment.ts) requires user ownership; [canonical auth](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/node/missionControl/missionControlAuthentication.ts) rejects Bot/organization principal shapes. | [Non-user tests](https://github.com/microsoft/vscode/blob/0622f8dd27e63028aee8f5cc94131dd7650fafe9/src/vs/platform/agentHost/test/node/missionControlAuthentication.test.ts). Managed attestation/IMDS/cloud-isolation assumptions belong to separately reviewed variants. | + +Implementation, regression evidence and deployment qualification are separate. See [remaining gaps](https://github.com/microsoft/vscode/blob/roblou%2Fagents%2Ftypescript-vscode-agent-host-prototype/src/vs/platform/agentHost/node/missionControl/MISSION_CONTROL_PRODUCTION_GAPS.md) for open work and accepted deferrals. + +(Written by Copilot) diff --git a/src/vs/platform/agentHost/node/missionControl/missionControlAuthentication.ts b/src/vs/platform/agentHost/node/missionControl/missionControlAuthentication.ts new file mode 100644 index 00000000000000..361db9ff02eacc --- /dev/null +++ b/src/vs/platform/agentHost/node/missionControl/missionControlAuthentication.ts @@ -0,0 +1,270 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createHash, randomBytes } from 'crypto'; +import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import type { IMissionControlCredentialSealingRequest } from '../../common/agentService.js'; +import type { AuthenticateParams } from '../../common/agent.js'; +import type { IHostEncryptionKey } from '../../common/cloudSandboxAgentHost.js'; +import { JsonRpcErrorCodes, ProtocolError } from '../../common/state/sessionProtocol.js'; + +type KeyUse = 'auth-token' | 'mcp-auth-token'; + +interface ISealingKey { + readonly use: KeyUse; + readonly publicKey: Uint8Array; + readonly privateKey: Uint8Array; + readonly keyId: string; +} + +type Sodium = typeof import('libsodium-wrappers').default; + +/** The caller authenticates the public key through MC HTTPS before crossing trusted local IPC. */ +export async function sealMissionControlCredential(request: IMissionControlCredentialSealingRequest): Promise { + if (!request.token || request.token.length > 32 * 1024 || request.resource.length > 8192 || request.key.algorithm !== 'x25519-sealedbox' + || (request.key.use !== 'auth-token' && request.key.use !== 'mcp-auth-token') + || (request.challenge !== undefined && !/^[0-9a-f]{32}$/.test(request.challenge))) { + throw new Error('Invalid Mission Control credential sealing request'); + } + new URL(request.resource); + const publicKey = Buffer.from(request.key.public_key, 'base64'); + const keyId = createHash('sha256').update(publicKey).digest().subarray(0, 8).toString('base64url'); + if (publicKey.length !== 32 || publicKey.toString('base64') !== request.key.public_key || keyId !== request.key.key_id) { + throw new Error('Invalid Mission Control recipient key'); + } + const sodium = (await import('libsodium-wrappers')).default; + await sodium.ready; + const plaintext = sodium.from_string(JSON.stringify({ + cty: 'text', value: request.token, + ctx: { + purpose: request.key.use, resource: request.resource, + ...(request.challenge ? { connection: { challenge: request.challenge, nonce: randomBytes(16).toString('hex'), issuedAt: Math.floor(Date.now() / 1000) } } : {}), + }, + })); + try { + const ciphertext = sodium.crypto_box_seal(plaintext, publicKey); + return `copilot-sealed.v1.${keyId}.${Buffer.from(ciphertext).toString('base64url')}`; + } finally { + sodium.memzero(plaintext); + } +} + +/** Process-local sealing keys; private material never crosses IPC or the relay. */ +export class MissionControlSealing extends Disposable { + private static _implementation: Sodium | undefined; + private readonly _sodium: Sodium; + private readonly _keys: readonly ISealingKey[]; + + constructor(keys?: readonly { use: KeyUse; privateKey: Uint8Array }[]) { + super(); + const sodium = MissionControlSealing._implementation; + if (!sodium) { + throw new Error('Mission Control sealing is not initialized'); + } + this._sodium = sodium; + this._keys = (keys ?? [ + { use: 'auth-token', privateKey: sodium.crypto_box_keypair().privateKey }, + { use: 'mcp-auth-token', privateKey: sodium.crypto_box_keypair().privateKey }, + ]).map(key => { + const publicKey = sodium.crypto_scalarmult_base(key.privateKey); + return { ...key, publicKey, keyId: createHash('sha256').update(publicKey).digest().subarray(0, 8).toString('base64url') }; + }); + this._register(toDisposable(() => { + for (const key of this._keys) { + sodium.memzero(key.privateKey); + } + })); + } + + static async ready(): Promise { + const sodium = (await import('libsodium-wrappers')).default; + await sodium.ready; + this._implementation = sodium; + } + + get advertisedKeys(): readonly IHostEncryptionKey[] { + return this._keys.map(key => ({ + key_id: key.keyId, use: key.use, algorithm: 'x25519-sealedbox', public_key: Buffer.from(key.publicKey).toString('base64'), + })); + } + + get rootMeta(): Record { + return { 'copilot.encryptionKeys': this.advertisedKeys.map(key => ({ keyId: key.key_id, use: key.use, algorithm: key.algorithm, publicKey: key.public_key })) }; + } + + open(token: string, use: KeyUse, resource: string): { token: string; connection?: Record } { + const parts = token.split('.'); + if (parts.length !== 4 || parts[0] !== 'copilot-sealed' || parts[1] !== 'v1' || token.length > 64 * 1024) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'Relay authentication requires a sealed v1 token'); + } + const key = this._keys.find(key => key.keyId === parts[2] && key.use === use); + const box = Buffer.from(parts[3], 'base64url'); + if (!key || box.toString('base64url') !== parts[3] || box.length < 48) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'Unknown sealing key or invalid sealed token'); + } + let plaintext: Uint8Array; + try { + plaintext = this._sodium.crypto_box_seal_open(box, key.publicKey, key.privateKey); + } catch { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'Unable to open sealed token'); + } + try { + const inner: unknown = JSON.parse(Buffer.from(plaintext).toString('utf8')); + if (!isObject(inner) || inner.cty !== 'text' || typeof inner.value !== 'string' || !inner.value + || !isObject(inner.ctx) || inner.ctx.purpose !== use + || (inner.ctx.resource !== undefined && inner.ctx.resource !== resource) + || (inner.ctx.connection !== undefined && !isObject(inner.ctx.connection))) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'Invalid sealed authentication context'); + } + return { token: inner.value, connection: inner.ctx.connection }; + } catch (error) { + if (error instanceof ProtocolError) { + throw error; + } + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'Invalid sealed authentication plaintext'); + } finally { + this._sodium.memzero(plaintext); + } + } +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** Validates a GitHub credential without retaining it or exposing response content. */ +export async function resolveMissionControlOwner(fetcher: typeof fetch, apiOrigin: string, credential: string): Promise { + if (!/^[\x21-\x7e]+$/.test(credential)) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'Invalid GitHub credential encoding'); + } + const response = await fetcher(new URL('user', `${apiOrigin.replace(/\/$/, '')}/`), { + headers: { Authorization: `Bearer ${credential}`, Accept: 'application/vnd.github+json' }, + redirect: 'error', + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) { + const denied = response.status === 401 || (response.status === 403 + && !response.headers.has('Retry-After') && response.headers.get('X-RateLimit-Remaining') !== '0'); + throw new ProtocolError(denied ? JsonRpcErrorCodes.InvalidParams : JsonRpcErrorCodes.InternalError, `GitHub identity validation failed (${response.status})`); + } + let user: unknown; + try { + user = await response.json(); + } catch (error) { + if (error instanceof SyntaxError) { + throw new Error('GitHub identity validation returned invalid JSON'); + } + throw error; + } + if (!isObject(user) || !Number.isSafeInteger(user.id) || (user.id as number) <= 0 || user.type !== 'User') { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'Mission Control requires a canonical GitHub user identity'); + } + return String(user.id); +} + +/** One challenge and replay ledger per AHP handshake generation. */ +export class MissionControlAuthentication { + private _challenge = randomBytes(16).toString('hex'); + private readonly _seen = new Map(); + private _time = 0; + private _generation = 0; + private _authenticated = false; + private _closed = false; + private _identityValidation: Promise | undefined; + + constructor( + private readonly _sealing: MissionControlSealing, + private readonly _owner: string, + private readonly _apiOrigin: string, + private readonly _fetch: typeof fetch, + private readonly _requireBinding: boolean, + private readonly _isCurrentIdentityAuthority: () => boolean = () => true, + ) { } + + beginHandshake(): void { + this._generation++; + this._authenticated = false; + this._challenge = randomBytes(16).toString('hex'); + this._seen.clear(); + this._identityValidation = undefined; + } + + get authenticated(): boolean { + return this._authenticated && !this._closed && this._isCurrentIdentityAuthority(); + } + + dispose(): void { + this._closed = true; + this._generation++; + this._authenticated = false; + this._seen.clear(); + this._identityValidation = undefined; + } + + get handshakeMeta(): Record { + return { + 'copilot.encryptionRequired': ['auth-token', 'mcp-auth-token'], + 'copilot.authChallenge': { challenge: this._challenge, responseMaxAgeSeconds: 300, required: this._requireBinding }, + }; + } + + async authenticate(params: AuthenticateParams): Promise { + if (this._closed || !this._isCurrentIdentityAuthority()) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidRequest, 'Relay authentication is closed'); + } + const generation = this._generation; + const resource = new URL(params.resource); + const identity = resource.origin === new URL(this._apiOrigin).origin; + if (!identity && !this.authenticated) { + const validation = this._identityValidation; + if (!validation) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidRequest, 'Authenticate the relay identity before MCP resources'); + } + await validation; + if (!this.authenticated || generation !== this._generation) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidRequest, 'Relay identity authentication is unavailable'); + } + } + const opened = this._sealing.open(params.token ?? '', identity ? 'auth-token' : 'mcp-auth-token', params.resource); + const binding = opened.connection; + if (!binding && this._requireBinding) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'A connection-bound sealed token is required'); + } + if (binding) { + this._time = Math.max(this._time, Math.floor(Date.now() / 1000)); + for (const [nonce, expiry] of this._seen) { + if (expiry < this._time) { + this._seen.delete(nonce); + } + } + if (binding.challenge !== this._challenge || typeof binding.nonce !== 'string' || !/^[0-9a-f]{32}$/.test(binding.nonce) + || !Number.isSafeInteger(binding.issuedAt) || Math.abs(this._time - (binding.issuedAt as number)) > 300 + || this._seen.has(binding.nonce) || this._seen.size >= 1024) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'Invalid or replayed sealed connection binding'); + } + this._seen.set(binding.nonce, (binding.issuedAt as number) + 300); + } + if (identity) { + const validation = resolveMissionControlOwner(this._fetch, this._apiOrigin, opened.token); + this._identityValidation = validation; + try { + if (await validation !== this._owner) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'Credential does not belong to the registered owner'); + } + } finally { + if (this._identityValidation === validation) { + this._identityValidation = undefined; + } + } + } + if (this._closed || generation !== this._generation || !this._isCurrentIdentityAuthority()) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidRequest, 'Relay authentication belongs to an expired handshake'); + } + if (identity) { + this._authenticated = true; + } + return { ...params, token: opened.token }; + } +} diff --git a/src/vs/platform/agentHost/node/missionControl/missionControlControl.ts b/src/vs/platform/agentHost/node/missionControl/missionControlControl.ts new file mode 100644 index 00000000000000..ba1f1ef014ac9b --- /dev/null +++ b/src/vs/platform/agentHost/node/missionControl/missionControlControl.ts @@ -0,0 +1,141 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createPublicKey, verify, type JsonWebKey } from 'crypto'; +import { hasKey } from '../../../../base/common/types.js'; +import type { IMissionControlMirrorBackfill } from './missionControlSessionMirror.js'; + +const MAX_SKEW_SECONDS = 300; +const P256_ORDER_HALF = BigInt('0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551') / 2n; + +export interface IMissionControlSpawn { + readonly kind: 'spawn_request'; + readonly client_id: string; + readonly spawn_request_id: string; + readonly passive?: boolean; + readonly signature: string; +} + +export interface IMissionControlSigningKey extends JsonWebKey { + readonly kid: string; + readonly kty: 'EC'; + readonly crv: 'P-256'; + readonly alg: 'ES256'; + readonly use: 'sig'; +} + +function object(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function decodePart(part: string): Record { + if (!/^[A-Za-z0-9_-]+$/.test(part)) { + throw new Error('Invalid control signature encoding'); + } + const decoded = Buffer.from(part, 'base64url'); + if (decoded.toString('base64url') !== part || decoded.length > 8192) { + throw new Error('Invalid control signature encoding'); + } + const value: unknown = JSON.parse(decoded.toString('utf8')); + if (!object(value)) { + throw new Error('Invalid control signature payload'); + } + return value; +} + +/** + * Validates a signed control request before it can open a client lane. + * A verifier is scoped to one registration and retains only bounded replay state. + */ +export class MissionControlControlVerifier { + private readonly _seen = new Map(); + + constructor( + private readonly _environmentId: string, + private readonly _ownerId: string, + private _keys: readonly IMissionControlSigningKey[], + private readonly _now: () => number = Date.now, + ) { + this.updateKeys(_keys); + } + + updateKeys(keys: readonly IMissionControlSigningKey[]): void { + if (keys.length === 0 || keys.length > 64 || new Set(keys.map(key => key?.kid)).size !== keys.length + || keys.some(key => !key || typeof key.kid !== 'string' || !key.kid || key.kid.length > 256 + || key.kty !== 'EC' || key.crv !== 'P-256' || key.alg !== 'ES256' || key.use !== 'sig' || hasKey(key, { d: true }))) { + throw new Error('Invalid Mission Control signing keys'); + } + for (const key of keys) { + createPublicKey({ key, format: 'jwk' }); + } + this._keys = keys.map(key => ({ ...key })); + } + + verify(value: unknown): IMissionControlSpawn { + if (!object(value) || value.kind !== 'spawn_request' || typeof value.client_id !== 'string' || !/^[A-Za-z0-9_-]+$/.test(value.client_id) + || typeof value.spawn_request_id !== 'string' || !/^[A-Za-z0-9_-]+$/.test(value.spawn_request_id) + || (value.passive !== undefined && typeof value.passive !== 'boolean') + || typeof value.signature !== 'string') { + throw new Error('Invalid Mission Control spawn request'); + } + const payload = this._verifyControl(value.signature, 'spawn_request', signed => signed.client_id === value.client_id + && signed.spawn_request_id === value.spawn_request_id && signed.passive === value.passive); + return { kind: 'spawn_request', client_id: String(payload.client_id), spawn_request_id: String(payload.spawn_request_id), passive: payload.passive === true, signature: value.signature }; + } + + verifyBackfill(value: unknown): IMissionControlMirrorBackfill { + if (!object(value) || value.kind !== 'backfill_request' || typeof value.signature !== 'string' + || value.environment_id !== this._environmentId || typeof value.session_id !== 'string' || value.session_id.length > 8192 + || value.ns !== 'ahp' || typeof value.request_id !== 'string' || !/^[A-Za-z0-9_-]+$/.test(value.request_id) + || typeof value.from_seq !== 'number' || typeof value.to_seq !== 'number' + || !Number.isSafeInteger(value.from_seq) || !Number.isSafeInteger(value.to_seq) + || value.from_seq < 0 || value.to_seq < value.from_seq || value.to_seq - value.from_seq >= 4096) { + throw new Error('Invalid Mission Control backfill request'); + } + const payload = this._verifyControl(value.signature, 'backfill_request', signed => + ['environment_id', 'session_id', 'ns', 'from_seq', 'to_seq', 'request_id'].every(key => signed[key] === value[key])); + return { + kind: 'backfill_request', environment_id: this._environmentId, session_id: String(payload.session_id), ns: 'ahp', + from_seq: value.from_seq, to_seq: value.to_seq, request_id: String(payload.request_id), + }; + } + + private _verifyControl(compactSignature: string, kind: 'spawn_request' | 'backfill_request', matches: (payload: Record) => boolean): Record { + const [encodedHeader, encodedClaims, encodedSignature, extra] = compactSignature.split('.'); + if (!encodedHeader || !encodedClaims || !encodedSignature || extra !== undefined) { + throw new Error('Invalid control signature'); + } + const header = decodePart(encodedHeader); + const claims = decodePart(encodedClaims); + const key = this._keys.find(candidate => candidate.kid === header.kid); + if (header.alg !== 'ES256' || header.typ !== 'JWT' || !key || key.kty !== 'EC' || key.crv !== 'P-256' || key.alg !== 'ES256' || key.use !== 'sig') { + throw new Error('Untrusted Mission Control signing key'); + } + const signature = Buffer.from(encodedSignature, 'base64url'); + if (signature.toString('base64url') !== encodedSignature || signature.length !== 64 + || BigInt(`0x${signature.subarray(32).toString('hex')}`) > P256_ORDER_HALF + || !verify('sha256', Buffer.from(`${encodedHeader}.${encodedClaims}`), { key: createPublicKey({ key, format: 'jwk' }), dsaEncoding: 'ieee-p1363' }, signature)) { + throw new Error('Invalid Mission Control signature'); + } + const now = Math.floor(this._now() / 1000); + const payload = claims.payload; + if (!Number.isInteger(claims.iat) || Math.abs(now - (claims.iat as number)) > MAX_SKEW_SECONDS + || typeof claims.jti !== 'string' || !claims.jti || claims.environment_id !== this._environmentId + || claims.user_id !== this._ownerId || claims.kind !== kind + || !object(payload) || payload.kind !== kind || !matches(payload)) { + throw new Error('Control request does not match this environment'); + } + for (const [nonce, time] of this._seen) { + if (time < now) { + this._seen.delete(nonce); + } + } + if (this._seen.has(claims.jti) || this._seen.size >= 1024) { + throw new Error('Replay or exhausted control replay window'); + } + this._seen.set(claims.jti, (claims.iat as number) + MAX_SKEW_SECONDS); + return payload; + } +} diff --git a/src/vs/platform/agentHost/node/missionControl/missionControlEnvironment.ts b/src/vs/platform/agentHost/node/missionControl/missionControlEnvironment.ts new file mode 100644 index 00000000000000..1218c71c5c9d19 --- /dev/null +++ b/src/vs/platform/agentHost/node/missionControl/missionControlEnvironment.ts @@ -0,0 +1,649 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { randomUUID } from 'crypto'; +import { mkdir, open, readFile, rename, unlink } from 'fs/promises'; +import { realpathSync } from 'fs'; +import { join } from '../../../../base/common/path.js'; +import { disposableLongTimeout, raceTimeout, Sequencer } from '../../../../base/common/async.js'; +import { combinedDisposable, Disposable, MutableDisposable, toDisposable, type IDisposable } from '../../../../base/common/lifecycle.js'; +import type { Event } from '../../../../base/common/event.js'; +import type { IMissionControlOptions } from '../../common/agentService.js'; +import { parseGroupName } from '../../common/webPubSub/groups.js'; +import { RELIABLE_JSON_SUBPROTOCOL } from '../../common/webPubSub/framing.js'; +import { PROTOCOL_VERSION } from '../../common/state/protocol/version/registry.js'; +import { MissionControlControlVerifier, type IMissionControlSigningKey } from './missionControlControl.js'; +import { MissionControlProtocolServer, type IMissionControlSocket } from './missionControlProtocolServer.js'; +import { MissionControlAuthentication, MissionControlSealing, resolveMissionControlOwner } from './missionControlAuthentication.js'; +import { MissionControlSessionMirror } from './missionControlSessionMirror.js'; + +interface IEnvironmentResponse { + readonly id: string; + readonly user_id: string; + readonly owner_id: string; + readonly owner_type: 'user'; + readonly kind: 'user-local'; + readonly webpubsub: { + readonly url: string; + readonly subprotocol: string; + readonly access_token: string; + readonly expires_at?: string; + readonly hub?: string; + readonly groups: { readonly control: string; readonly ingest_ack?: string }; + }; +} + +const heartbeatInterval = 60_000; + +export interface IMissionControlEnvironmentHost { + readonly userDataPath: string; + readonly name: string; + readonly fetch: typeof fetch; + readonly attach: (server: MissionControlProtocolServer, initialRoots: readonly string[], getRoots: () => readonly string[]) => IDisposable; + readonly onError: (error: unknown) => void; + readonly socketFactory?: (url: string, protocol: string) => IMissionControlSocket; + readonly getSessionCount?: () => Promise; + readonly getRemoteControlPolicy?: () => Promise | undefined>; + readonly onReady?: (environmentId: string) => void; + readonly getIdentityApiBase?: () => string; + readonly onDidChangeIdentityAuthority?: Event; + readonly createMirror?: (environmentId: string) => { readonly mirror: MissionControlSessionMirror; readonly source: IDisposable }; + readonly onDidChangeRemoteControlPolicy?: Event; +} + +interface IEnvironmentCapabilities { + readonly ahp_version: string; + readonly features: readonly string[]; + readonly current_sessions: number; +} + +function heartbeatRetryAfter(value: string, now: number): number | undefined { + const trimmed = value.trim(); + const delay = /^\d+$/.test(trimmed) + ? Number(trimmed) * 1000 + : /^[a-z]/i.test(trimmed) ? Date.parse(trimmed) - now : NaN; + if (!Number.isFinite(delay) || !Number.isSafeInteger(delay) || !Number.isSafeInteger(now + delay)) { + return undefined; + } + return Math.max(0, delay); +} + +function parseEnvironment(value: unknown): IEnvironmentResponse { + if (!value || typeof value !== 'object') { + throw new Error('Mission Control returned an invalid environment'); + } + const environment = value as Partial; + const wps = environment.webpubsub; + const owner = environment.owner_id ?? environment.user_id; + if (typeof environment.id !== 'string' || !/^[A-Za-z0-9_-]+$/.test(environment.id) + || typeof owner !== 'string' || !/^[A-Za-z0-9_-]+$/.test(owner) || owner.startsWith('org_') + || (environment.owner_id !== undefined && (typeof environment.owner_id !== 'string' || !environment.owner_id)) + || (environment.owner_type !== undefined && environment.owner_type !== 'user') + || ((environment.owner_id === undefined) !== (environment.owner_type === undefined)) + || (environment.user_id !== undefined && environment.user_id !== owner) || environment.kind !== 'user-local' + || typeof wps?.url !== 'string' || typeof wps.access_token !== 'string' || !wps.access_token + || wps.subprotocol !== RELIABLE_JSON_SUBPROTOCOL || typeof wps.groups?.control !== 'string') { + throw new Error('Mission Control returned an invalid user-local environment'); + } + return { ...environment, id: environment.id, kind: 'user-local', user_id: owner, owner_id: owner, owner_type: 'user', webpubsub: wps }; +} + +/** + * Process-owned Mission Control registration and WPS lifetime. + * The first account owns the process; a different window cannot rebind it. + */ +export class MissionControlEnvironment extends Disposable { + private readonly _server = this._register(new MutableDisposable()); + private readonly _handler = this._register(new MutableDisposable()); + private readonly _sealing = this._register(new MutableDisposable()); + private readonly _mirror = this._register(new MutableDisposable()); + private readonly _mirrorAttachment = this._register(new MutableDisposable()); + private readonly _mirrorSource = this._register(new MutableDisposable()); + private _mirrorEnvironment: string | undefined; + private readonly _configuration = new Sequencer(); + private readonly _heartbeat = this._register(new MutableDisposable()); + private _heartbeatNotBefore = 0; + private _nextHeartbeatAt = 0; + private _ownerAccount: string | undefined; + private _canonicalOwner: string | undefined; + private _identityApiBase: string | undefined; + private _policy: string | undefined; + private _options: IMissionControlOptions | undefined; + private _roots: readonly string[] = []; + private _initialRoots: readonly string[] | undefined; + private _environment: IEnvironmentResponse | undefined; + private _currentCheckIn: { readonly generation: number; readonly operation: Promise } | undefined; + private _generation = 0; + private _configurationEpoch = 0; + private _policyRefresh = 0; + private _appliedPolicyRefresh = 0; + private _retryDelay = 500; + private _credentialRejected = false; + private _signingKeys: readonly IMissionControlSigningKey[] | undefined; + private _signingKeysFetchedAt = 0; + private _verifier: MissionControlControlVerifier | undefined; + private _tokenExpiresAt: number | undefined; + private _requiresBootstrap = false; + + constructor(private readonly _host: IMissionControlEnvironmentHost) { + super(); + this._register(toDisposable(() => { + this._withdraw(); + })); + if (_host.onDidChangeIdentityAuthority) { + this._register(_host.onDidChangeIdentityAuthority(() => { + if (this._options?.live && this._identityApiBase !== this._getIdentityApiBase()) { + void this.configure(undefined).catch(this._host.onError); + } + })); + } + if (_host.onDidChangeRemoteControlPolicy) { + this._register(_host.onDidChangeRemoteControlPolicy(() => { + this._policyRefresh++; + if (this._options?.live) { + void this._checkIn().catch(this._host.onError); + } + })); + } + } + + /** Renderer withdrawals identify their requested account; unbound withdrawal is reserved for process-owned cleanup. */ + configure(options: IMissionControlOptions | undefined, withdrawingAccountId?: string): Promise { + if (!options) { + if (withdrawingAccountId !== undefined && this._ownerAccount !== undefined && withdrawingAccountId !== this._ownerAccount) { + return Promise.reject(new Error('Only the Agent Host owner can withdraw Mission Control registration')); + } + const registered = this._environment; + const previous = this._options; + this._withdraw(); + return this._configuration.queue(async () => { + if (registered && previous && Date.now() >= this._heartbeatNotBefore) { + await this._request( + `cmc_internal/api/agents/environments/${encodeURIComponent(registered.id)}/heartbeat`, + this._createHeartbeat('offline'), + previous, + ); + } + }); + } + const epoch = this._configurationEpoch; + return this._configuration.queue(() => this._configure(options, epoch)); + } + + get environmentId(): string | undefined { return this._environment?.id; } + get isEnabled(): boolean { return this._options?.live === true; } + + private _getIdentityApiBase(): string { + return this._host.getIdentityApiBase?.() ?? 'https://api.github.com'; + } + + private _createHeartbeat(status: 'online' | 'offline', capabilities?: IEnvironmentCapabilities): object { + return { + name: this._host.name, + status, + ...(capabilities ? { capabilities } : {}), + ...(this._sealing.value ? { encryption_keys: this._sealing.value.advertisedKeys } : {}), + }; + } + + private async _createRegistration(capabilities: IEnvironmentCapabilities, remoteControl: Record | undefined): Promise { + return { + name: this._host.name, + kind: 'user-local', + compute_id: await this._computeId(), + capabilities, + labels: { embedder: 'vscode' }, + ...(this._sealing.value ? { encryption_keys: this._sealing.value.advertisedKeys } : {}), + ...(remoteControl ? { managed_settings: { remoteControl } } : {}), + }; + } + + private _withdraw(): void { + this._configurationEpoch++; + this._generation++; + this._options = undefined; + this._environment = undefined; + this._roots = []; + this._initialRoots = undefined; + this._heartbeat.clear(); + this._handler.clear(); + this._mirrorAttachment.clear(); + this._server.clear(); + this._sealing.clear(); + this._mirror.value?.detach(); + this._verifier = undefined; + this._signingKeys = undefined; + this._tokenExpiresAt = undefined; + this._requiresBootstrap = false; + this._nextHeartbeatAt = 0; + } + + private async _configure(options: IMissionControlOptions, epoch: number): Promise { + if (this._store.isDisposed) { + throw new Error('Mission Control environment service is disposed'); + } + if (epoch !== this._configurationEpoch) { + return; + } + const endpoint = new URL(options.baseUrl); + if ((options.live ? endpoint.protocol !== 'https:' : endpoint.protocol !== 'http:' || !['localhost', '127.0.0.1', '[::1]'].includes(endpoint.hostname)) + || (options.live && endpoint.pathname !== '/') + || endpoint.username || endpoint.password || endpoint.search || endpoint.hash || !options.credential || !options.accountId || (!options.live && options.roots.length === 0)) { + throw new Error('Mission Control requires a safe origin, credential, account, and project roots'); + } + if (this._ownerAccount && this._ownerAccount !== options.accountId) { + throw new Error('Agent Host already belongs to another local account; restart the process to change owners'); + } + const identityApiBase = this._getIdentityApiBase(); + if (options.live && (new URL(identityApiBase).protocol !== 'https:' || (this._identityApiBase && this._identityApiBase !== identityApiBase))) { + throw new Error('Mission Control requires a stable HTTPS GitHub identity authority'); + } + const owner = options.live ? await resolveMissionControlOwner(this._host.fetch, identityApiBase, options.credential) : options.accountId; + if (epoch !== this._configurationEpoch || this._store.isDisposed) { + return; + } + if (options.live && identityApiBase !== this._getIdentityApiBase()) { + throw new Error('GitHub identity authority changed during Mission Control configuration'); + } + if (this._canonicalOwner && this._canonicalOwner !== owner) { + throw new Error('Credential does not match the Agent Host owner'); + } + const roots = options.roots.map(root => realpathSync(root)); + if (!roots.every(root => root.startsWith('/') || /^[A-Za-z]:\\/.test(root))) { + throw new Error('Mission Control projects must be absolute local directories'); + } + if (this._options) { + if (this._options.baseUrl !== options.baseUrl || this._options.live !== options.live || this._options.requireConnectionBinding !== options.requireConnectionBinding + || (!options.live && JSON.stringify(this._roots) !== JSON.stringify(roots))) { + throw new Error('Mission Control is already configured; disable it before changing its scope'); + } + this._roots = [...new Set([...this._roots, ...roots])]; + if (this._options.credential !== options.credential || this._credentialRejected) { + this._generation++; + this._handler.clear(); + this._mirrorAttachment.clear(); + this._server.clear(); + this._heartbeat.clear(); + this._options = { ...options, roots: this._roots }; + this._credentialRejected = false; + await this._checkIn(); + } + return; + } + this._ownerAccount = options.accountId; + this._canonicalOwner = owner; + this._identityApiBase = identityApiBase; + this._generation++; + this._initialRoots ??= roots; + this._roots = [...new Set([...this._roots, ...roots])]; + this._options = { ...options, roots: this._roots }; + this._credentialRejected = false; + try { + if (options.live) { + await MissionControlSealing.ready(); + if (epoch !== this._configurationEpoch || this._store.isDisposed) { + return; + } + this._sealing.value = new MissionControlSealing(); + } + await this._checkIn(); + } catch (error) { + if (epoch !== this._configurationEpoch || this._store.isDisposed) { + throw error; + } + try { + const registered = this._environment; + const previous = this._options; + this._withdraw(); + if (registered && previous && Date.now() >= this._heartbeatNotBefore) { + await this._request(`cmc_internal/api/agents/environments/${encodeURIComponent(registered.id)}/heartbeat`, this._createHeartbeat('offline'), previous); + } + } catch (offlineError) { + this._host.onError(offlineError); + } + throw error; + } + } + + /** The native Agent Host owns profile writes; atomic replacement keeps the location-bound identity record complete. */ + private async _computeId(): Promise { + await mkdir(this._host.userDataPath, { recursive: true }); + const directory = realpathSync(this._host.userDataPath); + const path = join(directory, 'agent-host-mission-control-id'); + let contents: string | undefined; + try { + contents = (await readFile(path, 'utf8')).trim(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + const legacyId = contents !== undefined && /^[0-9a-f-]{36}$/.test(contents) ? contents : undefined; + if (contents !== undefined && legacyId === undefined) { + let record: { version?: number; id?: string; userDataDirectory?: string } | null; + try { + record = JSON.parse(contents); + } catch (error) { + if (error instanceof SyntaxError) { + throw new Error('Invalid persisted Mission Control compute identity'); + } + throw error; + } + if (!record || typeof record !== 'object' + || record.version !== 1 || typeof record.id !== 'string' || !/^[0-9a-f-]{36}$/.test(record.id) + || typeof record.userDataDirectory !== 'string' || !record.userDataDirectory) { + throw new Error('Invalid persisted Mission Control compute identity'); + } + if (record.userDataDirectory === directory) { + return record.id; + } + } + const id = legacyId ?? randomUUID(); + const temporaryPath = join(directory, `agent-host-mission-control-id.${randomUUID()}.tmp`); + const file = await open(temporaryPath, 'wx', 0o600); + try { + try { + await file.writeFile(JSON.stringify({ version: 1, id, userDataDirectory: directory })); + await file.sync(); + } finally { + await file.close(); + } + await rename(temporaryPath, path); + } finally { + await unlink(temporaryPath).catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOENT') { + throw error; + } + }); + } + return id; + } + + private async _request(path: string, body?: object, options = this._options): Promise { + if (!options) { + throw new Error('Mission Control is disabled'); + } + const generation = this._generation; + const response = await this._host.fetch(new URL(path, `${options.baseUrl.replace(/\/$/, '')}/`), { + method: body ? 'POST' : 'GET', + headers: { Authorization: `Bearer ${options.credential}`, ...(body ? { 'Content-Type': 'application/json' } : {}) }, + body: body ? JSON.stringify(body) : undefined, + redirect: 'error', + signal: AbortSignal.timeout(15_000), + }); + if (path.endsWith('/heartbeat')) { + this._nextHeartbeatAt = Date.now() + heartbeatInterval; + const retryAfter = response.headers.get('Retry-After'); + if (retryAfter !== null) { + const now = Date.now(); + const delay = heartbeatRetryAfter(retryAfter, now); + if (delay === undefined) { + this._host.onError(new Error('Mission Control heartbeat returned an invalid Retry-After header')); + } else { + this._heartbeatNotBefore = Math.max(this._heartbeatNotBefore, now + delay); + } + } + } + if (!response.ok) { + if ((response.status === 401 || response.status === 403) && generation === this._generation && this._options === options) { + this._credentialRejected = true; + this._handler.clear(); + this._mirrorAttachment.clear(); + this._server.clear(); + } + throw new Error(`Mission Control request failed (${response.status})`); + } + try { + return await response.json(); + } catch (error) { + if (error instanceof SyntaxError) { + throw new Error('Mission Control returned invalid JSON'); + } + throw error; + } + } + + private async _checkIn(): Promise { + const generation = this._generation; + if (this._currentCheckIn?.generation === generation) { + await this._currentCheckIn.operation; + if (generation === this._generation && this._options && this._appliedPolicyRefresh !== this._policyRefresh) { + return this._checkIn(); + } + return; + } + const operation = this._doCheckIn(); + const current = { generation, operation }; + this._currentCheckIn = current; + try { + await operation; + if (generation === this._generation) { + this._retryDelay = 500; + } + } catch (error) { + if (generation === this._generation) { + this._retryDelay = Math.min(30_000, this._retryDelay * 2); + } + throw error; + } finally { + if (this._currentCheckIn === current) { + this._currentCheckIn = undefined; + } + if (generation === this._generation && this._options && !this._store.isDisposed && !this._credentialRejected) { + const recovering = this._environment !== undefined && (!this._server.value || this._server.value.isClosed); + const heartbeatDelay = Math.max(1000, this._nextHeartbeatAt - Date.now(), this._heartbeatNotBefore - Date.now()); + const tokenDelay = this._tokenExpiresAt === undefined ? heartbeatDelay : Math.max(1000, this._tokenExpiresAt - Date.now() - 30_000); + const refreshingPolicy = this._options.live && this._appliedPolicyRefresh !== this._policyRefresh; + this._heartbeat.value = disposableLongTimeout(() => { + this._heartbeat.clear(); + this._checkIn().catch(this._host.onError); + }, refreshingPolicy ? this._retryDelay : this._requiresBootstrap ? Math.max(this._retryDelay, heartbeatDelay) : recovering ? this._retryDelay : Math.min(heartbeatDelay, tokenDelay)); + } + } + } + + private async _doCheckIn(): Promise { + const options = this._options; + const generation = this._generation; + const policyRefresh = this._policyRefresh; + if (!options || this._credentialRejected) { + return; + } + const waiting = this._environment && Date.now() < Math.max(this._heartbeatNotBefore, this._nextHeartbeatAt); + const recovering = this._environment && (!this._server.value || this._server.value.isClosed); + const refreshToken = !this._requiresBootstrap && this._environment && (recovering || (this._tokenExpiresAt !== undefined && Date.now() >= this._tokenExpiresAt - 30_000)); + if (waiting && !refreshToken && this._appliedPolicyRefresh === policyRefresh) { + return; + } + if (refreshToken && this._environment) { + const reply = await this._request(`cmc_internal/api/agents/environments/${encodeURIComponent(this._environment.id)}/token`, {}, options); + if (generation !== this._generation || this._options !== options || this._store.isDisposed) { + return; + } + if (!reply || typeof reply !== 'object' || Array.isArray(reply)) { + throw new Error('Mission Control returned an invalid token bootstrap'); + } + const token = reply as { url?: unknown; wps_endpoint?: unknown; expires_at?: unknown }; + const bootstrap = parseEnvironment({ ...this._environment, webpubsub: { ...reply, url: token.wps_endpoint ?? token.url } }).webpubsub; + const cached = this._environment.webpubsub; + if (bootstrap.url !== cached.url || bootstrap.subprotocol !== cached.subprotocol || bootstrap.hub !== cached.hub + || JSON.stringify(Object.entries(bootstrap.groups).sort()) !== JSON.stringify(Object.entries(cached.groups).sort())) { + this._requiresBootstrap = true; + throw new Error('Mission Control token refresh changed the environment bootstrap'); + } + if (typeof token.expires_at !== 'string' || !Number.isFinite(Date.parse(token.expires_at)) || Date.parse(token.expires_at) <= Date.now()) { + throw new Error('Mission Control returned an invalid token expiry'); + } + this._tokenExpiresAt = Date.parse(token.expires_at); + this._environment = { ...this._environment, webpubsub: bootstrap }; + if ((waiting || recovering) && this._appliedPolicyRefresh === policyRefresh) { + if (recovering) { + await this._connectRelay(options, generation); + } + return; + } + } + const capabilities = { ahp_version: PROTOCOL_VERSION, features: [], current_sessions: await this._boundedRegistrationWork(this._host.getSessionCount?.() ?? Promise.resolve(0)) }; + let remoteControl: Record | undefined; + if (options.live) { + if (!this._host.getRemoteControlPolicy) { + throw new Error('Cannot register before reading device remote-control policy'); + } + remoteControl = await this._boundedRegistrationWork(this._host.getRemoteControlPolicy()); + } + if (generation !== this._generation || this._options !== options || this._store.isDisposed) { + return; + } + const policy = JSON.stringify(remoteControl ?? null); + const register = !this._environment || (options.live && policy !== this._policy); + if (waiting && !register) { + this._appliedPolicyRefresh = policyRefresh; + if (recovering) { + await this._connectRelay(options, generation); + } + return; + } + const path = !register && this._environment + ? `cmc_internal/api/agents/environments/${encodeURIComponent(this._environment.id)}/heartbeat` + : 'cmc_internal/api/agents/environments/register'; + const body = !register + ? this._createHeartbeat(options.live && (!this._server.value || this._server.value.isClosed) ? 'offline' : 'online', capabilities) + : await this._createRegistration(capabilities, remoteControl); + if (generation !== this._generation || this._options !== options || this._store.isDisposed) { + return; + } + const reply = await this._request(path, body, options); + if (generation !== this._generation || this._options !== options || this._store.isDisposed) { + return; + } + const response = parseEnvironment(reply); + if (response.user_id !== this._canonicalOwner || (this._environment && (response.id !== this._environment.id || response.owner_id !== this._environment.owner_id))) { + throw new Error('Mission Control environment owner changed'); + } + parseGroupName(response.webpubsub.groups.control, { expected: { uid: response.user_id, eid: response.id } }); + if (!response.webpubsub.groups.control.endsWith('.control')) { + throw new Error('Mission Control returned an invalid control lane'); + } + this._environment = response; + if (options.live && this._host.createMirror && (!this._mirror.value || this._mirrorEnvironment !== response.id)) { + if (!response.webpubsub.groups.ingest_ack) { + throw new Error('Mission Control returned no durable ingest acknowledgement lane'); + } + const group = parseGroupName(response.webpubsub.groups.ingest_ack, { expected: { uid: response.user_id, eid: response.id } }); + if (group.scope !== 'env' || group.lane !== 'ingest-ack') { + throw new Error('Mission Control returned an invalid ingest acknowledgement lane'); + } + this._mirrorAttachment.clear(); + this._mirrorSource.clear(); + this._mirror.clear(); + const created = this._host.createMirror(response.id); + this._mirror.value = created.mirror; + this._mirrorSource.value = created.source; + this._mirrorEnvironment = response.id; + } + this._requiresBootstrap = false; + if (response.webpubsub.expires_at !== undefined) { + const expiry = Date.parse(response.webpubsub.expires_at); + if (!Number.isFinite(expiry) || expiry <= Date.now()) { + throw new Error('Mission Control returned an invalid token expiry'); + } + this._tokenExpiresAt = expiry; + } + this._policy = policy; + this._appliedPolicyRefresh = policyRefresh; + await this._connectRelay(options, generation); + } + + private async _connectRelay(options: IMissionControlOptions, generation: number): Promise { + const response = this._environment; + if (!response || generation !== this._generation || this._store.isDisposed) { + return; + } + if (!this._signingKeys || Date.now() - this._signingKeysFetchedAt >= 5 * 60_000) { + const jwks = await this._request('cmc_internal/api/agents/environments/.well-known/jwks.json', undefined, options); + if (generation !== this._generation || this._store.isDisposed) { + return; + } + const keys = (jwks as { keys?: IMissionControlSigningKey[] } | null)?.keys; + if (!Array.isArray(keys) || keys.length === 0 || keys.length > 64) { + throw new Error('Mission Control returned no usable signing keys'); + } + if (this._verifier) { + this._verifier.updateKeys(keys); + } else { + this._verifier = new MissionControlControlVerifier(response.id, response.user_id, keys); + } + this._signingKeys = keys; + this._signingKeysFetchedAt = Date.now(); + } + if (this._server.value?.isClosed) { + this._handler.clear(); + this._server.clear(); + } + if (this._server.value) { + return; + } + const sealing = this._sealing.value; + const verifier = this._verifier; + if (!verifier) { + throw new Error('Mission Control signing keys are unavailable'); + } + const identityApiBase = this._identityApiBase; + if (sealing && !identityApiBase) { + throw new Error('Mission Control identity authority is unavailable'); + } + const server = new MissionControlProtocolServer( + response.webpubsub, response.user_id, response.id, + verifier, + this._host.socketFactory, + error => this._host.onError(error), + sealing && identityApiBase ? () => new MissionControlAuthentication(sealing, response.user_id, identityApiBase, this._host.fetch, options.requireConnectionBinding === true, () => identityApiBase === this._getIdentityApiBase() && generation === this._generation) : undefined, + sealing?.rootMeta, + this._mirror.value, + ); + this._server.value = server; + this._handler.value = combinedDisposable(this._host.attach(server, this._initialRoots ?? [], () => this._roots), server.onClose(() => { + if (generation === this._generation && !this._store.isDisposed && this._server.value === server && this._options && !this._credentialRejected) { + this._heartbeat.value = disposableLongTimeout(() => { + this._checkIn().catch(this._host.onError); + }, this._retryDelay); + } + })); + await server.connect(); + if (generation !== this._generation) { + if (this._server.value === server) { + this._handler.clear(); + this._server.clear(); + } + return; + } + if (this._mirror.value) { + const attachment = this._mirror.value.attach(event => { + try { + return server.publishMirrorEvent(event); + } catch (error) { + server.dispose(); + throw error; + } + }); + const onClose = server.onClose(() => attachment.dispose()); + this._mirrorAttachment.value = combinedDisposable(attachment, onClose); + } + if (options.live && Date.now() >= this._heartbeatNotBefore) { + await this._request(`cmc_internal/api/agents/environments/${encodeURIComponent(response.id)}/heartbeat`, this._createHeartbeat('online'), options); + } + if (generation === this._generation && this._options === options && !this._store.isDisposed) { + this._host.onReady?.(response.id); + } + } + + private async _boundedRegistrationWork(operation: Promise): Promise { + const result = await raceTimeout(operation.then(value => ({ value })), 15_000); + if (!result) { + throw new Error('Mission Control registration metadata timed out'); + } + return result.value; + } +} diff --git a/src/vs/platform/agentHost/node/missionControl/missionControlHost.ts b/src/vs/platform/agentHost/node/missionControl/missionControlHost.ts new file mode 100644 index 00000000000000..b375bdf41a54f5 --- /dev/null +++ b/src/vs/platform/agentHost/node/missionControl/missionControlHost.ts @@ -0,0 +1,152 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableStore, type IDisposable } from '../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { URI } from '../../../../base/common/uri.js'; +import { INativeEnvironmentService } from '../../../environment/common/environment.js'; +import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; +import { ILogService } from '../../../log/common/log.js'; +import { IProductService } from '../../../product/common/productService.js'; +import { IAgentService } from '../../common/agentService.js'; +import type { AgentHostLaunchKind } from '../../common/agentHostTelemetry.js'; +import type { AgentHostClientFileSystemProvider } from '../../common/agentHostClientFileSystemProvider.js'; +import { parseAnnotationsUri } from '../../common/annotationsUri.js'; +import { parseChangesetUri } from '../../common/changesetUri.js'; +import { ISessionDataService } from '../../common/sessionDataService.js'; +import { parseChatUri } from '../../common/state/sessionState.js'; +import { IAgentHostGitHubEndpointService } from '../agentHostGitHubEndpointService.js'; +import { IAgentHostProviderService } from '../agentHostProviderService.js'; +import { IAgentHostProxyResolver } from '../agentHostProxyResolver.js'; +import { AgentHostStateManager, IAgentHostStateManager } from '../agentHostStateManager.js'; +import { ProtocolServerHandler } from '../protocolServerHandler.js'; +import { MissionControlEnvironment } from './missionControlEnvironment.js'; +import type { MissionControlProtocolServer } from './missionControlProtocolServer.js'; +import { MissionControlSdkEventSource } from './missionControlSdkEventSource.js'; +import { MissionControlSessionMirror } from './missionControlSessionMirror.js'; + +interface IMissionControlHostOptions { + readonly hostLaunchKind: AgentHostLaunchKind; + readonly clientFileSystemProvider: AgentHostClientFileSystemProvider; + readonly trackProtocolHandler: (handler: ProtocolServerHandler) => IDisposable; +} + +export function getMissionControlEnvironmentName(product: IProductService): string { + if (product.quality === 'stable') { + return 'VS Code'; + } + if (product.quality === 'insider') { + return 'VS Code Insiders'; + } + return product.nameShort === 'Code - OSS' || product.nameShort === 'Code - OSS Dev' ? 'VS Code OSS' : product.nameShort; +} + +/** Entry-owned adapter from the native runtime graph to the registration lifecycle. */ +export class MissionControlHost extends Disposable { + readonly environment: MissionControlEnvironment; + + constructor( + private readonly _options: IMissionControlHostOptions, + @IAgentService private readonly _agentService: IAgentService, + @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager, + @ISessionDataService private readonly _sessionDataService: ISessionDataService, + @IAgentHostProviderService private readonly _providerService: IAgentHostProviderService, + @IAgentHostProxyResolver proxyResolver: IAgentHostProxyResolver, + @IAgentHostGitHubEndpointService gitHubEndpoints: IAgentHostGitHubEndpointService, + @INativeEnvironmentService environmentService: INativeEnvironmentService, + @IProductService productService: IProductService, + @ILogService private readonly _logService: ILogService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { + super(); + this.environment = this._register(this._instantiationService.createInstance(MissionControlEnvironment, { + userDataPath: environmentService.userDataPath, + name: getMissionControlEnvironmentName(productService), + fetch: (input, init) => proxyResolver.fetch(input, init), + attach: (relay, roots, getRoots) => this._attachRelay(relay, roots, getRoots), + onError: error => this._logService.error('[AgentHost] Mission Control failure', error), + getSessionCount: async () => (await this._agentService.listSessions()).length, + getRemoteControlPolicy: () => this._readRemoteControlPolicy(), + onReady: environmentId => this._logService.info(`[AgentHost] Mission Control ready; environmentId=${environmentId}`), + getIdentityApiBase: () => gitHubEndpoints.getApiBaseUri(), + onDidChangeIdentityAuthority: gitHubEndpoints.onDidChange, + createMirror: environmentId => this._createMirror(environmentId), + })); + } + + private _readRemoteControlPolicy(): Promise | undefined> { + const provider = this._providerService.getProvider('copilotcli'); + if (!provider?.getRemoteControlManagedSettings) { + throw new Error('Copilot runtime cannot read device remote-control policy'); + } + return provider.getRemoteControlManagedSettings(); + } + + private _attachRelay(relay: MissionControlProtocolServer, roots: readonly string[], getRoots: () => readonly string[]): IDisposable { + const handler = this._instantiationService.createInstance( + ProtocolServerHandler, + this._agentService, + this._stateManager, + relay, + { + hostLaunchKind: this._options.hostLaunchKind, + allowExtensionMethods: false, + relayRoots: relay.rootMeta ? undefined : roots, + relayRootMeta: relay.rootMeta, + relayResourceRoots: readOnly => this._resourceRoots(readOnly, getRoots()), + defaultDirectory: roots[0] ? URI.file(roots[0]).toString() : undefined, + }, + this._options.clientFileSystemProvider, + ); + return this._options.trackProtocolHandler(handler); + } + + private _resourceRoots(readOnly: boolean, grantedRoots: readonly string[]): readonly string[] { + const summaries = this._stateManager.getOverlaySessionSummaries(); + const workspaces = summaries.flatMap(summary => summary.workingDirectories ?? []) + .map(directory => URI.parse(directory)).filter(directory => directory.scheme === Schemas.file).map(directory => directory.fsPath); + const contentRoots = readOnly ? summaries.map(summary => this._sessionDataService.getSessionDataDir(URI.parse(summary.resource)).fsPath) : []; + return [...grantedRoots, ...workspaces, ...contentRoots]; + } + + private _createMirror(environmentId: string): { readonly mirror: MissionControlSessionMirror; readonly source: IDisposable } { + const mirror = this._instantiationService.createInstance(MissionControlSessionMirror, environmentId, {}); + const sources = new DisposableStore(); + try { + const sdk = sources.add(this._instantiationService.createInstance(MissionControlSdkEventSource, environmentId, mirror, () => this.environment.isEnabled)); + const registered = new Set(); + sources.add(this._stateManager.onDidEmitEnvelope(envelope => { + const channel = parseChatUri(envelope.channel)?.session ?? parseAnnotationsUri(envelope.channel)?.sessionUri + ?? parseChangesetUri(envelope.channel)?.sessionUri ?? envelope.channel; + const session = this._stateManager.getSessionSummary(channel); + if (!session) { + return; + } + try { + if (!this.environment.isEnabled) { + if (registered.has(session.resource)) { + mirror.reportSourceLag(session.resource, 1); + } + return; + } + if (!registered.has(session.resource)) { + mirror.registerSession(session.resource); + registered.add(session.resource); + mirror.setLifecycle(session.resource, 'started'); + } + mirror.enqueue(envelope, session.resource); + sdk.observeSession(session.resource); + } catch (error) { + this._logService.error('[AgentHost] Mission Control mirror admission failed', error); + } + })); + return { mirror, source: sources }; + } catch (error) { + sources.dispose(); + mirror.dispose(); + throw error; + } + } +} diff --git a/src/vs/platform/agentHost/node/missionControl/missionControlProtocolServer.ts b/src/vs/platform/agentHost/node/missionControl/missionControlProtocolServer.ts new file mode 100644 index 00000000000000..444365dff28eed --- /dev/null +++ b/src/vs/platform/agentHost/node/missionControl/missionControlProtocolServer.ts @@ -0,0 +1,506 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import WebSocket from 'ws'; +import { randomInt } from 'crypto'; +import { DeferredPromise, IntervalTimer, RunOnceScheduler } from '../../../../base/common/async.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js'; +import { hasKey } from '../../../../base/common/types.js'; +import { AgentHostClientConnectionKind } from '../../common/agentHostTelemetry.js'; +import type { AhpServerNotification, JsonRpcNotification, JsonRpcParseErrorResponse, JsonRpcRequest, JsonRpcResponse, ProtocolMessage } from '../../common/state/sessionProtocol.js'; +import type { IProtocolServer, IProtocolTransport } from '../../common/state/sessionTransport.js'; +import { Reassembler } from '../../common/webPubSub/chunking.js'; +import { buildPublish, parseInbound, RELIABLE_JSON_SUBPROTOCOL } from '../../common/webPubSub/framing.js'; +import { parseGroupName } from '../../common/webPubSub/groups.js'; +import { MissionControlControlVerifier } from './missionControlControl.js'; +import { MissionControlAuthentication } from './missionControlAuthentication.js'; +import type { AuthenticateParams } from '../../common/agent.js'; +import { MissionControlSessionMirror, type MissionControlMirrorEvent } from './missionControlSessionMirror.js'; + +export interface IMissionControlSocket { + send(data: string): void; + close(): void; + on(event: 'message', listener: (data: Buffer | string) => void): void; + on(event: 'close', listener: (code: number) => void): void; + on(event: 'error', listener: (error: Error) => void): void; +} + +export interface IMissionControlBootstrap { + readonly url: string; + readonly access_token: string; + readonly groups: { readonly control: string; readonly ingest_ack?: string }; +} + +const relayKeepAliveTimeoutMs = 15 * 60_000; +const maxQueuedMirrorFrames = 128; +const maxQueuedMirrorBytes = 16 * 1024 * 1024; + +function newConnectionGeneration(): number { + return randomInt(1, 2 ** 48); +} + +class MissionControlLane extends Disposable implements IProtocolTransport { + readonly clientConnectionKind = AgentHostClientConnectionKind.WebPubSub; + get relayClientId(): string { return this.clientId; } + get relayPassive(): boolean { return this.passive; } + get relayAuthenticated(): boolean | undefined { return this._authentication?.authenticated; } + get relayHandshakeMeta(): Record | undefined { + return { ...this._authentication?.handshakeMeta, 'copilot.keepAliveTimeoutMs': relayKeepAliveTimeoutMs, ...(this.passive ? { 'copilot.passive': true } : {}) }; + } + relayAuthenticate(params: AuthenticateParams): Promise { + if (!this._authentication) { + throw new Error('Relay authentication is unavailable'); + } + return this._authentication.authenticate(params); + } + private readonly _onMessage = this._register(new Emitter()); + readonly onMessage = this._onMessage.event; + private readonly _onClose = this._register(new Emitter()); + readonly onClose = this._onClose.event; + private _closed = false; + private _active = false; + private _hasHandshake = false; + private _handshakeId: number | string | undefined; + private readonly _earlyMessages: ProtocolMessage[] = []; + readonly generation = newConnectionGeneration(); + lastReceived = Date.now(); + get isClosed(): boolean { return this._closed; } + + constructor(readonly clientId: string, readonly passive: boolean, private readonly _publish: (group: string, message: unknown, generation: number) => void, private readonly _prefix: string, private readonly _authentication?: MissionControlAuthentication, private readonly _rehandshake?: (lane: MissionControlLane, message: object) => void, private readonly _didClose?: (lane: MissionControlLane) => void) { + super(); + } + + receive(message: unknown): void { + if (this._closed || typeof message !== 'object' || message === null || Array.isArray(message)) { + return; + } + this.lastReceived = Date.now(); + const request = message as { id?: unknown; method?: unknown; params?: { clientId?: unknown } }; + if ((request.method === 'initialize' || request.method === 'reconnect') && request.params?.clientId !== this.clientId) { + this.dispose(); + return; + } + if (request.method === 'initialize' || request.method === 'reconnect') { + if (this._active && (this._hasHandshake || this._handshakeId !== undefined) && this._rehandshake) { + this._rehandshake(this, message); + return; + } + this._handshakeId = typeof request.id === 'number' || typeof request.id === 'string' ? request.id : undefined; + this._authentication?.beginHandshake(); + } + if (!this._authentication && (request.method === 'authenticate' || request.method === 'resourceRequest' || request.method === 'dispatchAction' || request.method === 'setClientManagedSettingsPermissions')) { + this.dispose(); + return; + } + if (!this._active) { + if (this._earlyMessages.length >= 32) { + this.dispose(); + } else { + this._earlyMessages.push(message as ProtocolMessage); + } + return; + } + this._onMessage.fire(message as ProtocolMessage); + } + + activate(): void { + this._active = true; + for (const message of this._earlyMessages.splice(0)) { + this._onMessage.fire(message); + } + } + + send(message: ProtocolMessage | AhpServerNotification | JsonRpcNotification | JsonRpcParseErrorResponse | JsonRpcResponse | JsonRpcRequest): void { + if (this._closed) { + throw new Error('Mission Control lane closed'); + } + const response = hasKey(message, { id: true }); + if (response && this._handshakeId !== undefined && message.id === this._handshakeId) { + this._hasHandshake = hasKey(message, { result: true }); + this._handshakeId = undefined; + } + try { + this._publish(`${this._prefix}.${response ? 'to-client' : 'broadcast'}`, message, this.generation); + } catch (error) { + this.dispose(); + throw error; + } + } + + override dispose(): void { + if (!this._closed) { + this._closed = true; + this._authentication?.dispose(); + this._onClose.fire(); + this._didClose?.(this); + } + super.dispose(); + } +} + +/** + * A bounded virtual AHP server over one reliable-JSON WPS connection. + * The bootstrap and owner are supplied by the trusted registration lifecycle. + */ +export class MissionControlProtocolServer extends Disposable implements IProtocolServer { + private readonly _onClose = this._register(new Emitter()); + readonly onClose = this._onClose.event; + private readonly _onConnection = this._register(new Emitter()); + readonly onConnection = this._onConnection.event; + readonly address = undefined; + private readonly _lanes = this._register(new DisposableMap()); + private readonly _reassembler = new Reassembler(); + private readonly _sweep = this._register(new IntervalTimer()); + private readonly _ackTimeout = this._register(new RunOnceScheduler(() => this.dispose(), 30_000)); + private readonly _ready = new DeferredPromise(); + private readonly _pending = new Set(); + private readonly _outbound: { ackId: number; frame: string; mirror?: boolean }[] = []; + private _outboundBytes = 0; + private _queuedMirrorFrames = 0; + private _queuedMirrorBytes = 0; + private _draining = false; + private readonly _joins = new Map(); + private readonly _bootstrapJoins = new Set(); + private _socket: IMissionControlSocket | undefined; + private _ackId = 0; + private _sequence = 0; + private _connected = false; + private _closed = false; + private _ackDeadline: number | undefined; + + get isClosed(): boolean { return this._closed; } + + constructor( + private readonly _bootstrap: IMissionControlBootstrap, + private readonly _owner: string, + private readonly _environment: string, + private readonly _verifier: MissionControlControlVerifier, + private readonly _socketFactory: (url: string, protocol: string) => IMissionControlSocket = (url, protocol) => new WebSocket(url, protocol), + private readonly _onError: (error: Error) => void = () => { }, + private readonly _authenticationFactory?: () => MissionControlAuthentication, + readonly rootMeta?: Record, + private readonly _mirror?: MissionControlSessionMirror, + ) { + super(); + parseGroupName(_bootstrap.groups.control, { expected: { uid: _owner, eid: _environment } }); + } + + connect(): Promise { + if (this._socket || this._closed) { + throw new Error('Mission Control socket already connected or disposed'); + } + const url = new URL(this._bootstrap.url); + if (url.username || url.password || url.search || url.hash + || (this._authenticationFactory ? url.protocol !== 'wss:' : url.protocol !== 'ws:' || !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) { + throw new Error('Mission Control returned an unsafe WPS endpoint'); + } + url.searchParams.set('access_token', this._bootstrap.access_token); + this._socket = this._socketFactory(url.toString(), RELIABLE_JSON_SUBPROTOCOL); + this._socket.on('message', data => this._receive(data)); + this._socket.on('close', code => { + if (!this._closed) { + this.dispose(); + this._onError(new Error(`Mission Control WPS socket closed (code ${code})`)); + } + }); + this._socket.on('error', error => { + if (!this._closed) { + this.dispose(); + this._onError(error); + } + }); + this._sweep.cancelAndSet(() => { + this._reassembler.sweepExpired(); + for (const [id, lane] of this._lanes) { + if (Date.now() - lane.lastReceived >= relayKeepAliveTimeoutMs) { + this._lanes.deleteAndLeak(id)?.dispose(); + this._send({ type: 'leaveGroup', group: `user.${this._owner}.env.${this._environment}.client.${id}.to-host` }); + } + } + }, 15_000); + this._waitForAck(); + return this._ready.p; + } + + private _waitForAck(): void { + this._ackDeadline ??= Date.now() + 30_000; + this._ackTimeout.schedule(Math.max(0, this._ackDeadline - Date.now())); + } + + private _send(frame: object): void { + if (this._closed || !this._socket || this._pending.size + this._joins.size >= 128) { + throw new Error('Mission Control WPS publish queue unavailable'); + } + this._socket.send(JSON.stringify(frame)); + } + + private _join(group: string): void { + const ackId = ++this._ackId; + this._joins.set(ackId, group); + this._waitForAck(); + this._send({ type: 'joinGroup', group, ackId }); + } + + private _publish(group: string, payload: unknown, generation?: number): void { + const frames = buildPublish({ group, payload, generation, nextAckId: () => ++this._ackId }).map(frame => ({ ackId: frame.ackId, frame: JSON.stringify(frame) })); + this._enqueue(frames); + } + + publishMirrorEvent(event: MissionControlMirrorEvent): undefined | false { + if (this._closed) { + throw new Error('Mission Control mirror transport closed'); + } + const ackId = this._ackId + 1; + const frame = JSON.stringify({ ...event, ackId }); + const bytes = Buffer.byteLength(frame); + if (this._queuedMirrorFrames >= maxQueuedMirrorFrames || this._queuedMirrorBytes + bytes > maxQueuedMirrorBytes + || this._outbound.length >= 512 || this._outboundBytes + bytes > 64 * 1024 * 1024) { + return false; + } + this._ackId = ackId; + this._enqueue([{ ackId, frame, mirror: true }]); + return undefined; + } + + private _enqueue(frames: readonly { ackId: number; frame: string; mirror?: boolean }[]): void { + const bytes = frames.reduce((total, frame) => total + Buffer.byteLength(frame.frame), 0); + if (this._closed || this._outbound.length + frames.length > 512 || this._outboundBytes + bytes > 64 * 1024 * 1024) { + throw new Error('Mission Control ordered publish queue exceeded its limit'); + } + this._outbound.push(...frames); + this._outboundBytes += bytes; + for (const frame of frames) { + if (frame.mirror) { + this._queuedMirrorFrames++; + this._queuedMirrorBytes += Buffer.byteLength(frame.frame); + } + } + this._drain(); + } + + private _drain(): void { + if (this._draining || this._closed) { + return; + } + this._draining = true; + try { + while (this._pending.size === 0 && this._outbound.length > 0) { + const next = this._outbound.shift()!; + const bytes = Buffer.byteLength(next.frame); + this._outboundBytes -= bytes; + if (next.mirror) { + this._queuedMirrorFrames--; + this._queuedMirrorBytes -= bytes; + } + this._pending.add(next.ackId); + this._waitForAck(); + this._socket!.send(next.frame); + } + } finally { + this._draining = false; + } + } + + private _receive(data: Buffer | string): void { + if (this._closed) { + return; + } + try { + if (data.length > 2 * 1024 * 1024) { + throw new Error('Mission Control WPS frame exceeds receive limit'); + } + const frame: unknown = JSON.parse(data.toString()); + if (typeof frame !== 'object' || frame === null) { + throw new Error('Invalid WPS frame'); + } + const fields = frame as Record; + if (fields.type === 'system' && fields.event === 'connected') { + this._connected = true; + this._bootstrapJoins.add(this._bootstrap.groups.control); + if (this._mirror && this._bootstrap.groups.ingest_ack) { + this._bootstrapJoins.add(this._bootstrap.groups.ingest_ack); + } + this._join(this._bootstrap.groups.control); + if (this._mirror && this._bootstrap.groups.ingest_ack) { + this._join(this._bootstrap.groups.ingest_ack); + } + return; + } + if (fields.type === 'ack') { + const id = fields.ackId; + if (typeof id !== 'number') { + throw new Error('Invalid WPS acknowledgement'); + } + const group = this._joins.get(id); + this._joins.delete(id); + const pending = this._pending.delete(id); + if ((group || pending) && fields.success !== true && !(pending && (fields.error as { name?: string } | undefined)?.name === 'Duplicate')) { + this.dispose(); + throw new Error('Mission Control WPS operation rejected'); + } + if (group && group !== this._bootstrap.groups.control && group !== this._bootstrap.groups.ingest_ack) { + const clientId = parseGroupName(group).scope === 'client' ? group.split('.')[5] : undefined; + const lane = clientId ? this._lanes.get(clientId) : undefined; + if (lane) { + this._onConnection.fire(lane); + lane.activate(); + } + } + if ((group === this._bootstrap.groups.control || group === this._bootstrap.groups.ingest_ack) + && group !== undefined) { + this._bootstrapJoins.delete(group); + if (this._bootstrapJoins.size === 0) { + this._ready.complete(); + } + } + if (this._joins.size === 0 && this._pending.size === 0) { + this._ackTimeout.cancel(); + this._ackDeadline = undefined; + } + this._drain(); + this._mirror?.resumePublishing(); + return; + } + if (!this._connected) { + throw new Error('WPS message before handshake'); + } + if (fields.type === 'message' && fields.sequenceId !== undefined) { + const id = fields.sequenceId; + if (typeof id !== 'number' || !Number.isSafeInteger(id) || id <= 0) { + throw new Error('Invalid WPS sequence'); + } + const duplicate = id <= this._sequence; + this._sequence = Math.max(this._sequence, id); + this._send({ type: 'sequenceAck', sequenceId: this._sequence }); + if (duplicate) { + return; + } + } + const envelope = typeof fields.data === 'object' && fields.data !== null ? fields.data as { readonly kind?: unknown } : undefined; + if (fields.type === 'message' && fields.from === 'group' && fields.group === this._bootstrap.groups.control + && fields.dataType === 'json' && (envelope?.kind === 'spawn_request' || envelope?.kind === 'backfill_request')) { + this._receiveControl(fields.data); + return; + } + if (this._mirror && fields.type === 'message' && fields.from === 'group' && fields.group === this._bootstrap.groups.ingest_ack + && fields.dataType === 'json' && typeof fields.data === 'object' && fields.data !== null && hasKey(fields.data, { watermarks: true })) { + this._mirror.ingestAck(fields.data); + return; + } + if (fields.type === 'message' && fields.from === 'group' && typeof fields.group === 'string') { + const group = parseGroupName(fields.group, { expected: { uid: this._owner, eid: this._environment } }); + if (group.scope === 'client' && group.lane === 'to-host') { + if (this._authenticationFactory && fields.fromUserId !== this._owner) { + throw new Error('WPS publisher does not match the registered owner'); + } + const lane = this._lanes.get(group.cid); + if (lane) { + lane.lastReceived = Date.now(); + } + } + } + const result = parseInbound(frame, { reassembler: this._reassembler, groupValidation: { expected: { uid: this._owner, eid: this._environment } } }); + if (result.kind !== 'payload' && result.kind !== 'batch') { + return; + } + if (result.group.scope === 'env' && result.group.lane === 'control') { + if (result.kind === 'payload') { + this._receiveControl(result.payload); + } + } else if (result.group.scope === 'env' && result.group.lane === 'ingest-ack' && result.kind === 'payload') { + this._mirror?.ingestAck(result.payload); + } else if (result.group.scope === 'client' && result.group.lane === 'to-host') { + if (this._authenticationFactory && fields.fromUserId !== this._owner) { + throw new Error('WPS publisher does not match the registered owner'); + } + for (const payload of result.kind === 'batch' ? result.payloads : [result.payload]) { + this._receiveForClient(result.group.cid, payload); + } + } + } catch (error) { + this._onError(error instanceof Error ? error : new Error(String(error))); + if (!this._ready.isSettled) { + this.dispose(); + } + } + } + + private _receiveControl(payload: unknown): void { + if (typeof payload === 'object' && payload !== null && Object.getOwnPropertyDescriptor(payload, 'kind')?.value === 'backfill_request') { + const backfill = this._verifier.verifyBackfill(payload); + if (!this._mirror) { + throw new Error('Mission Control session mirroring is unavailable'); + } + this._mirror.backfill(backfill); + } else { + this._openLane(payload); + } + } + + private _receiveForClient(clientId: string, payload: unknown): void { + let lane = this._lanes.get(clientId); + if (lane?.isClosed) { + lane = this._createLane(clientId, lane.passive); + this._onConnection.fire(lane); + lane.activate(); + } + lane?.receive(payload); + } + + private _openLane(payload: unknown): void { + const spawn = this._verifier.verify(payload); + const existing = this._lanes.get(spawn.client_id); + if (existing) { + if (existing.passive !== (spawn.passive === true)) { + throw new Error('Mission Control cannot change the role of an existing client lane'); + } + return; + } + if (this._lanes.size >= 32) { + throw new Error('Mission Control client lane capacity reached'); + } + this._createLane(spawn.client_id, spawn.passive === true); + this._join(`user.${this._owner}.env.${this._environment}.client.${spawn.client_id}.to-host`); + } + + private _createLane(clientId: string, passive: boolean): MissionControlLane { + const prefix = `user.${this._owner}.env.${this._environment}.client.${clientId}`; + const lane = new MissionControlLane(clientId, passive, (group, payload, generation) => this._publish(group, payload, generation), prefix, this._authenticationFactory?.(), (previous, message) => { + if (this._closed || this._lanes.get(clientId) !== previous) { + return; + } + previous.dispose(); + this._receiveForClient(clientId, message); + }, ended => { + if (!this._closed && this._lanes.get(clientId) === ended) { + const ackId = ++this._ackId; + const frame = JSON.stringify({ type: 'sendToGroup', group: `${prefix}.to-client`, ackId, dataType: 'json', noEcho: true, data: { kind: 'closed', generation: ended.generation } }); + try { + this._enqueue([{ ackId, frame }]); + } catch (error) { + this._onError(error instanceof Error ? error : new Error(String(error))); + this.dispose(); + return; + } + } + }); + this._lanes.set(clientId, lane); + return lane; + } + + override dispose(): void { + if (!this._closed) { + this._closed = true; + if (!this._ready.isSettled) { + this._ready.error(new Error('Mission Control WPS connection closed before joining control group')); + } + this._socket?.close(); + this._socket = undefined; + this._onClose.fire(); + } + super.dispose(); + } +} diff --git a/src/vs/platform/agentHost/node/missionControl/missionControlSdkEventSource.ts b/src/vs/platform/agentHost/node/missionControl/missionControlSdkEventSource.ts new file mode 100644 index 00000000000000..733aa6e38699c4 --- /dev/null +++ b/src/vs/platform/agentHost/node/missionControl/missionControlSdkEventSource.ts @@ -0,0 +1,381 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { Limiter, RunOnceScheduler, timeout } from '../../../../base/common/async.js'; +import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ILogService } from '../../../log/common/log.js'; +import type { IAgent, IAgentChatSessionEvent } from '../../common/agent.js'; +import { ISessionDataService } from '../../common/sessionDataService.js'; +import { parseChatUri } from '../../common/state/sessionState.js'; +import { createAgentChatContext } from '../agentChatContext.js'; +import { IAgentHostProviderService } from '../agentHostProviderService.js'; +import { AgentHostStateManager, IAgentHostStateManager } from '../agentHostStateManager.js'; +import { MissionControlSessionMirror } from './missionControlSessionMirror.js'; + +const reservationSize = 1024; +const maxQueuedEvents = 256; +const maxQueueBytes = 2 * 1024 * 1024; +const maxSourceBytes = 32 * 1024 * 1024; +const maxHistoryEvents = 10_000; + +interface SourceSession { + readonly session: string; + readonly chat: string; + readonly provider: IAgent; + readonly queue: { readonly event: IAgentChatSessionEvent; readonly bytes: number }[]; + readonly seen: Set; + bytes: number; + limit: number | undefined; + historyPending: boolean; + draining: Promise | undefined; + dropped: number; + title: string | undefined; + failed: boolean; + dirty: boolean; + journalCursor: string | undefined; + admittedCursor: string | undefined; + pendingCursor: string | undefined; +} + +/** Owns optional native metadata replication; provider work and durable writes never gate local AHP dispatch. */ +export class MissionControlSdkEventSource extends Disposable { + private readonly _sessions = new Map(); + private readonly _cancellation = this._register(new CancellationTokenSource()); + private readonly _work = this._register(new Limiter(4)); + private _bytes = 0; + private readonly _retry = this._register(new RunOnceScheduler(() => { + for (const source of this._sessions.values()) { + if (source.failed) { + source.failed = false; + this._drain(source); + } + } + }, 5000)); + + constructor( + private readonly _environmentId: string, + private readonly _mirror: MissionControlSessionMirror, + private readonly _isEnabled: () => boolean, + @IAgentHostProviderService private readonly _providers: IAgentHostProviderService, + @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager, + @ISessionDataService private readonly _sessionData: ISessionDataService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + this._register(this._mirror.setSdkAcknowledgementHandler((session, journalEventId) => { + const source = this._sessions.get(session); + if (source && !this._store.isDisposed) { + source.pendingCursor = journalEventId; + source.dirty = true; + this._drain(source); + } + })); + this._register(toDisposable(() => { + this._cancellation.cancel(); + this._sessions.clear(); + this._bytes = 0; + })); + const attach = (provider: IAgent) => { + if (provider.onDidChatSessionEvent) { + this._register(provider.onDidChatSessionEvent(event => this._accept(provider, event))); + } + }; + this._register(this._providers.onDidRegisterProvider(attach)); + for (const provider of this._providers.getProviders()) { + attach(provider); + } + for (const session of this._stateManager.getSessionUris()) { + this.observeSession(session); + } + } + + observeSession(session: string): void { + if (!this._isEnabled() || this._store.isDisposed) { + return; + } + const summary = this._stateManager.getSessionSummary(session); + const chat = summary?.defaultChat; + const provider = this._providers.getProviderForSession(session); + if (!summary || !chat || !provider?.onDidChatSessionEvent) { + return; + } + let source = this._sessions.get(session); + if (!source) { + this._mirror.registerSession(session); + source = { + session, chat, provider, queue: [], seen: new Set(), bytes: 0, limit: undefined, + historyPending: true, draining: undefined, dropped: 0, title: undefined, failed: false, dirty: false, + journalCursor: undefined, admittedCursor: undefined, pendingCursor: undefined, + }; + this._sessions.set(session, source); + } + source.dirty = true; + this._drain(source); + } + + private _accept(provider: IAgent, event: IAgentChatSessionEvent): void { + const session = parseChatUri(event.chat.toString())?.session; + if (!session) { + return; + } + if (!this._isEnabled()) { + const source = this._sessions.get(session); + if (source) { + source.historyPending = true; + source.title = undefined; + } + return; + } + this.observeSession(session); + const source = this._sessions.get(session); + if (!source || source.provider !== provider || source.chat !== event.chat.toString()) { + return; + } + try { + const json = JSON.stringify(event.data); + const bytes = Buffer.byteLength(json); + if (source.queue.length >= maxQueuedEvents || source.bytes + bytes > maxQueueBytes || this._bytes + bytes > maxSourceBytes) { + source.dropped++; + this._drain(source); + return; + } + source.queue.push({ event: { ...event, data: JSON.parse(json) }, bytes }); + source.bytes += bytes; + this._bytes += bytes; + this._drain(source); + } catch { + this._logService.error('[MissionControlSdkEventSource] Native metadata could not be serialized', { session }); + source.dropped++; + } + } + + private _drain(source: SourceSession): void { + if (source.draining || source.failed) { + return; + } + const operation = this._work.queue(() => this._run(source)).catch(error => { + if (!this._cancellation.token.isCancellationRequested) { + source.failed = true; + this._logService.error('[MissionControlSdkEventSource] Metadata replication paused; native session remains available', { session: source.session }, error); + if (!this._retry.isScheduled()) { + this._retry.schedule(); + } + } + }).finally(() => { + source.draining = undefined; + if (this._isEnabled() && !this._store.isDisposed && (source.dirty || source.queue.length || source.dropped)) { + this._drain(source); + } + }); + source.draining = operation; + } + + private async _reserve(source: SourceSession): Promise { + if (source.limit !== undefined && (this._mirror.getSdkNextSequence(source.session) ?? source.limit) + 2 < source.limit) { + return; + } + if (!this._isEnabled() || this._store.isDisposed) { + return; + } + const database = this._sessionData.openDatabase(URI.parse(source.session)); + try { + const key = `missionControl.sdkSequence.${this._environmentId}`; + const stored = await database.object.getMetadata(key); + if (source.limit === undefined) { + source.journalCursor = await database.object.getMetadata(`missionControl.sdkJournalCursor.${this._environmentId}`); + } + const first = source.limit ?? (stored === undefined ? 0 : Number(stored)); + const limit = first + reservationSize; + if (!Number.isSafeInteger(first) || first < 0 || !Number.isSafeInteger(limit)) { + throw new Error('Invalid persisted Mission Control SDK sequence'); + } + if (!this._isEnabled() || this._store.isDisposed) { + return; + } + await database.object.setMetadata(key, String(limit)); + if (this._store.isDisposed || !this._isEnabled()) { + return; + } + this._mirror.reserveSdkSequences(source.session, first, limit); + source.limit = limit; + } finally { + database.dispose(); + } + } + + private async _publish(source: SourceSession, event: IAgentChatSessionEvent, replay = false): Promise { + if (source.seen.has(event.id)) { + return true; + } + await this._reserve(source); + const payload = { type: event.type, data: event.data }; + if (replay && this._isEnabled() && !this._store.isDisposed) { + await this._mirror.waitForSdkCapacity(source.session, payload, event.timestamp, this._cancellation.token); + await this._reserve(source); + } + if (!this._isEnabled() || this._store.isDisposed) { + return false; + } + if (source.seen.size >= maxQueuedEvents * 2) { + source.seen.delete(source.seen.values().next().value!); + } + source.seen.add(event.id); + this._mirror.enqueueSdk(source.session, payload, event.timestamp, event.persisted ? event.id : undefined); + if (event.persisted) { + source.admittedCursor = event.id; + } + return true; + } + + private async _persistCursor(source: SourceSession): Promise { + const cursor = source.pendingCursor; + if (!cursor) { + return; + } + const database = this._sessionData.openDatabase(URI.parse(source.session)); + try { + await database.object.setMetadata(`missionControl.sdkJournalCursor.${this._environmentId}`, cursor); + source.journalCursor = cursor; + if (source.pendingCursor === cursor) { + source.pendingCursor = undefined; + } + } finally { + database.dispose(); + } + } + + private async _replayHistory(source: SourceSession): Promise { + if (!source.provider.readChatSessionEvents) { + return; + } + const chat = URI.parse(source.chat); + const context = createAgentChatContext(this._stateManager, source.session, source.chat); + const cursor = source.admittedCursor ?? source.journalCursor; + const overlap = new Set(); + const iterator = source.provider.readChatSessionEvents(chat, context, this._cancellation.token, cursor, id => { + if (cursor === undefined && source.queue.some(item => item.event.id === id)) { + overlap.add(id); + } + })[Symbol.asyncIterator](); + let foundCursor = cursor === undefined; + let count = 0; + try { + while (this._isEnabled() && !this._store.isDisposed) { + let next: IteratorResult; + try { + next = await iterator.next(); + } catch (error) { + if (!this._cancellation.token.isCancellationRequested) { + this._logService.error('[MissionControlSdkEventSource] Historical metadata reconciliation is incomplete; continuing live replication', { session: source.session }, error); + } + return; + } + if (next.done) { + if (cursor === undefined) { + for (let index = source.queue.length - 1; index >= 0; index--) { + const item = source.queue[index]; + if (overlap.has(item.event.id)) { + source.queue.splice(index, 1); + source.bytes -= item.bytes; + this._bytes -= item.bytes; + } + } + } + if (!foundCursor) { + this._logService.warn('[MissionControlSdkEventSource] Native metadata journal no longer contains its replay cursor; waiting for fresh runtime events', { session: source.session }); + } + return; + } + const event = next.value; + if (event.chat.toString() !== source.chat) { + throw new Error('Native metadata history belongs to another chat'); + } + if (!foundCursor) { + if (event.id === cursor) { + foundCursor = true; + source.seen.add(event.id); + } + continue; + } + if (++count % maxHistoryEvents === 0) { + await timeout(0, this._cancellation.token); + } + if (!await this._publish(source, event, true)) { + source.historyPending = true; + return; + } + } + source.historyPending = true; + } catch (error) { + source.historyPending = true; + throw error; + } finally { + await iterator.return?.(); + } + } + + private async _run(source: SourceSession): Promise { + source.dirty = false; + if (!this._isEnabled() || this._store.isDisposed) { + return; + } + await this._reserve(source); + if (!this._isEnabled() || this._store.isDisposed) { + return; + } + await this._persistCursor(source); + if (source.historyPending) { + source.historyPending = false; + await this._replayHistory(source); + } + while (source.queue.length && this._isEnabled() && !this._store.isDisposed) { + const item = source.queue.shift()!; + source.bytes -= item.bytes; + this._bytes -= item.bytes; + try { + if (!await this._publish(source, item.event)) { + source.queue.unshift(item); + source.bytes += item.bytes; + this._bytes += item.bytes; + break; + } + } catch (error) { + source.queue.unshift(item); + source.bytes += item.bytes; + this._bytes += item.bytes; + throw error; + } + } + if (!this._isEnabled() || this._store.isDisposed) { + return; + } + if (source.dropped) { + await this._reserve(source); + if (!this._isEnabled() || this._store.isDisposed) { + return; + } + this._mirror.reportSdkSourceLag(source.session, source.dropped); + source.dropped = 0; + } + const title = this._stateManager.getSessionSummary(source.session)?.title; + if (title && title !== source.title && source.provider.synchronizeChatSessionTitle) { + try { + if (await source.provider.synchronizeChatSessionTitle(URI.parse(source.chat), title)) { + source.title = title; + } + } catch (error) { + this._logService.error('[MissionControlSdkEventSource] Native title synchronization failed; continuing metadata replication', { session: source.session }, error); + } + } + } + + async whenIdle(): Promise { + while ([...this._sessions.values()].some(source => source.draining)) { + await Promise.all([...this._sessions.values()].map(source => source.draining)); + } + } +} diff --git a/src/vs/platform/agentHost/node/missionControl/missionControlSessionMirror.ts b/src/vs/platform/agentHost/node/missionControl/missionControlSessionMirror.ts new file mode 100644 index 00000000000000..7ddee30156bffd --- /dev/null +++ b/src/vs/platform/agentHost/node/missionControl/missionControlSessionMirror.ts @@ -0,0 +1,751 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DeferredPromise, raceCancellationError, RunOnceScheduler } from '../../../../base/common/async.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { CancellationError } from '../../../../base/common/errors.js'; +import { Disposable, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { ILogService } from '../../../log/common/log.js'; +import type { ActionEnvelope } from '../../common/state/sessionActions.js'; +import { chunk, type ChunkEnvelope, type ChunkOptions, DEFAULT_MAX_CHUNK_BYTES } from '../../common/webPubSub/chunking.js'; + +export interface IMissionControlMirrorProject { + readonly uri: string; + readonly display_name: string; +} + +export interface IMissionControlSdkEvent { + readonly type: string; + readonly data: unknown; + readonly _truncated?: { readonly reason: 'oversize'; readonly bytes: number }; +} + +export type IMissionControlReplicationFrame = { + readonly environment_id: string; + readonly session_id: string; + readonly seq: number; + readonly at: string; + readonly project?: IMissionControlMirrorProject; +} & ( + | { readonly ns: 'ahp'; readonly payload: ChunkEnvelope } + | { readonly ns: 'sdk'; readonly payload: IMissionControlSdkEvent } + ); + +export type MissionControlMirrorFailure = + | { readonly namespace: 'ahp'; readonly reason: 'serialization_failed' | 'framing_failed' | 'spool_capacity'; readonly server_seq: number } + | { readonly namespace: 'ahp'; readonly reason: 'source_lag'; readonly skipped: number }; + +export interface IMissionControlSessionLifecycle { + readonly environment_id: string; + readonly session_id: string; + readonly kind: 'started' | 'completed' | 'failed' | 'aborted' | 'mirror_failed'; + readonly at: string; + readonly details?: Record | MissionControlMirrorFailure; +} + +/** The connection owner adds its transport-only ackId, without wrapping data in another ChunkEnvelope. */ +export type MissionControlMirrorEvent = + | { readonly type: 'event'; readonly event: 'sessionEvents'; readonly dataType: 'json'; readonly data: IMissionControlReplicationFrame } + | { readonly type: 'event'; readonly event: 'sessionLifecycle'; readonly dataType: 'json'; readonly data: IMissionControlSessionLifecycle }; + +/** Return false for transient queue pressure; throw for a failed transport. Never await service acknowledgements. */ +export type MissionControlMirrorSender = (event: MissionControlMirrorEvent) => undefined | false; + +/** Signature, owner, timestamp and nonce verification belong to the control ingress, before this call. */ +export interface IMissionControlMirrorBackfill { + readonly kind: 'backfill_request'; + readonly environment_id: string; + readonly session_id: string; + readonly ns: 'ahp'; + readonly from_seq: number; + readonly to_seq: number; + readonly request_id: string; +} + +const creditWindow = 1024; +const maxMetadataBytes = 16 * 1024; +const transportAckBytes = Buffer.byteLength(',"ackId":9007199254740991'); +const defaultLimits = { + maxSessionFrames: 10_000, + maxSessionBytes: 32 * 1024 * 1024, + maxTotalFrames: 100_000, + maxTotalBytes: 128 * 1024 * 1024, + maxSessions: 1024, + maxBackfillFrames: 4096, + maxBackfillRequests: 64, + maxFramesPerFlush: 128, + maxEventBytes: 1024 * 1024, +}; + +export interface IMissionControlSessionMirrorOptions extends Partial { + readonly chunkOptions?: ChunkOptions; + readonly now?: () => number; +} + +export interface IMissionControlMirrorStatus { + readonly nextSeq: number; + readonly publishedSeq: number; + readonly acknowledgedSeq: number; + readonly retainedFrames: number; + readonly retainedBytes: number; + readonly failure: MissionControlMirrorFailure | undefined; +} + +interface RetainedFrame { + readonly json: string; + readonly bytes: number; + readonly journalEventId?: string; +} + +interface BackfillRange { + nextSeq: number; + readonly toSeq: number; +} + +interface NamespaceSpool { + readonly frames: Map; + nextSeq: number; + publishedSeq: number; + acknowledgedSeq: number; + nextToSend: number; + bytes: number; +} + +interface SdkSpool extends NamespaceSpool { + sequenceLimit: number; + droppedSequence: number | undefined; + droppedCount: number; +} + +interface SessionSpool extends NamespaceSpool { + readonly sessionId: string; + readonly project: IMissionControlMirrorProject | undefined; + readonly backfills: BackfillRange[]; + sdk?: SdkSpool; + failure: MissionControlMirrorFailure | undefined; + failureEvent: string | undefined; + failurePending: boolean; + lifecycleEvent: string | undefined; +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isSequence(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +} + +function isSessionId(value: string): boolean { + return /^[A-Za-z][A-Za-z0-9+.-]*:\/[^\s]+$/.test(value); +} + +/** + * Process-owned, bounded AHP frame spool, independent of socket lifetimes and direct client dispatch. + * AHP restart continuity is unresolved; SDK sequence ranges are reserved durably by its source before admission. + */ +export class MissionControlSessionMirror extends Disposable { + private readonly _sessions = new Map(); + private readonly _ready = new Set(); + private readonly _scheduler = this._register(new RunOnceScheduler(() => this._flush(), 1)); + private readonly _limits: typeof defaultLimits; + private readonly _chunkOptions: ChunkOptions | undefined; + private readonly _now: () => number; + private _attachment: { readonly sender: MissionControlMirrorSender } | undefined; + private _publisherBlocked = false; + private _retainedFrames = 0; + private _retainedBytes = 0; + private _sdkRetainedFrames = 0; + private _sdkRetainedBytes = 0; + private _backfillFrames = 0; + private _backfillRequests = 0; + private _sdkAcknowledged: ((sessionId: string, journalEventId: string) => void) | undefined; + private readonly _sdkCapacityWaiters = new Map>(); + + constructor( + private readonly _environmentId: string, + options: IMissionControlSessionMirrorOptions, + @ILogService private readonly _logService: ILogService, + ) { + const { chunkOptions, now, ...limits } = options; + const resolvedLimits = { ...defaultLimits, ...limits }; + if (!_environmentId || Buffer.byteLength(_environmentId) > maxMetadataBytes + || Object.values(resolvedLimits).some(value => !Number.isSafeInteger(value) || value < 1) + || resolvedLimits.maxEventBytes > defaultLimits.maxEventBytes) { + throw new Error('Invalid Mission Control mirror limits or environment'); + } + super(); + this._limits = resolvedLimits; + this._chunkOptions = chunkOptions; + this._now = now ?? Date.now; + this._register(toDisposable(() => { + this.detach(); + for (const waiter of this._sdkCapacityWaiters.values()) { + waiter.error(new CancellationError()); + } + this._sdkCapacityWaiters.clear(); + this._sessions.clear(); + this._retainedFrames = this._retainedBytes = this._backfillFrames = this._backfillRequests = 0; + this._sdkRetainedFrames = this._sdkRetainedBytes = 0; + })); + } + + /** Register before observing actions; entries are never evicted or reused while this process owns the mirror. */ + registerSession(sessionId: string, project?: IMissionControlMirrorProject): void { + this._assertLive(); + if (!isSessionId(sessionId) || (project && (!project.uri || !project.display_name)) + || Buffer.byteLength(JSON.stringify({ environmentId: this._environmentId, sessionId, project })) > maxMetadataBytes) { + throw new Error('Invalid Mission Control mirror session metadata'); + } + const failureEvent = this._failureJson(sessionId, { + namespace: 'ahp', reason: 'serialization_failed', server_seq: Number.MAX_SAFE_INTEGER, + }); + if (Buffer.byteLength(failureEvent) + transportAckBytes > this._limits.maxEventBytes) { + throw new Error('Mission Control mirror session cannot fit an integrity signal'); + } + const existing = this._sessions.get(sessionId); + if (existing) { + if (existing.project?.uri !== project?.uri || existing.project?.display_name !== project?.display_name) { + throw new Error('Mission Control mirror project cannot change'); + } + return; + } + if (this._sessions.size >= this._limits.maxSessions) { + throw new Error('Mission Control mirror session capacity exhausted'); + } + this._sessions.set(sessionId, { + sessionId, project: project && { uri: project.uri, display_name: project.display_name }, + frames: new Map(), backfills: [], nextSeq: 0, publishedSeq: -1, acknowledgedSeq: -1, nextToSend: 0, + bytes: 0, failure: undefined, failureEvent: undefined, failurePending: false, lifecycleEvent: undefined, + }); + } + + /** The caller supplies the owning session, including for chat/changeset channels; admission never publishes inline. */ + enqueue(envelope: ActionEnvelope, sessionId: string): boolean { + const session = this._session(sessionId); + if (session.failure) { + return false; + } + if (!isSequence(envelope.serverSeq)) { + throw new Error('Invalid authoritative AHP serverSeq'); + } + let serialised: string; + try { + serialised = JSON.stringify(envelope); + if (serialised === undefined) { + throw new Error('Unserialisable AHP envelope'); + } + } catch { + this._fail(session, { namespace: 'ahp', reason: 'serialization_failed', server_seq: envelope.serverSeq }); + return false; + } + + let candidates: RetainedFrame[]; + try { + const at = this._timestamp(); + const overhead = Buffer.byteLength(this._frameJson(session, Number.MAX_SAFE_INTEGER, at, { kind: 'message', data: null })) + - Buffer.byteLength(JSON.stringify({ kind: 'message', data: null })) + transportAckBytes; + const payload: ActionEnvelope = JSON.parse(serialised); + const fragments = chunk(payload, { + ...this._chunkOptions, + maxChunkBytes: Math.min(this._chunkOptions?.maxChunkBytes ?? DEFAULT_MAX_CHUNK_BYTES, this._limits.maxEventBytes - overhead), + }); + if (!Number.isSafeInteger(session.nextSeq + fragments.length)) { + throw new Error('Mirror sequence exhausted'); + } + candidates = fragments.map((fragment, index) => { + const json = this._frameJson(session, session.nextSeq + index, at, fragment); + const bytes = Buffer.byteLength(json); + if (bytes + transportAckBytes > this._limits.maxEventBytes) { + throw new Error('Mirror event exceeds transport ceiling'); + } + return { json, bytes }; + }); + } catch { + this._fail(session, { namespace: 'ahp', reason: 'framing_failed', server_seq: envelope.serverSeq }); + return false; + } + const bytes = candidates.reduce((sum, frame) => sum + frame.bytes, 0); + if (session.frames.size + candidates.length > this._limits.maxSessionFrames || session.bytes + bytes > this._limits.maxSessionBytes + || this._retainedFrames + candidates.length > this._limits.maxTotalFrames || this._retainedBytes + bytes > this._limits.maxTotalBytes) { + this._fail(session, { namespace: 'ahp', reason: 'spool_capacity', server_seq: envelope.serverSeq }); + return false; + } + for (const frame of candidates) { + session.frames.set(session.nextSeq++, frame); + } + session.bytes += bytes; + this._retainedBytes += bytes; + this._retainedFrames += candidates.length; + this._wake(session); + return true; + } + + reportSourceLag(sessionId: string, skipped: number): void { + const session = this._session(sessionId); + if (!Number.isSafeInteger(skipped) || skipped < 1) { + throw new Error('Invalid authoritative AHP source lag'); + } + this._fail(session, { namespace: 'ahp', reason: 'source_lag', skipped }); + } + + /** Grants a durably reserved range; discarded reservations become permitted SDK sequence gaps after restart. */ + reserveSdkSequences(sessionId: string, first: number, limit: number): void { + const session = this._session(sessionId); + if (!isSequence(first) || !isSequence(limit) || limit - first < 2 + || (session.sdk && (first !== session.sdk.sequenceLimit || limit <= first))) { + throw new Error('Invalid SDK sequence reservation'); + } + if (session.sdk) { + session.sdk.sequenceLimit = limit; + } else { + session.sdk = { + frames: new Map(), nextSeq: first, publishedSeq: first - 1, acknowledgedSeq: first - 1, nextToSend: first, + bytes: 0, sequenceLimit: limit, droppedSequence: undefined, droppedCount: 0, + }; + } + } + + getSdkNextSequence(sessionId: string): number | undefined { + return this._session(sessionId).sdk?.nextSeq; + } + + /** SDK data is selected by its provider; oversized bodies use the portable placeholder, never chunking. */ + enqueueSdk(sessionId: string, event: IMissionControlSdkEvent, at: string, journalEventId?: string): boolean { + const session = this._session(sessionId); + const sdk = session.sdk; + if (!sdk || sdk.nextSeq + 1 >= sdk.sequenceLimit || !event.type || !Number.isFinite(Date.parse(at))) { + throw new Error('SDK sequence range or event is invalid'); + } + const { json, bytes } = this._sdkCandidate(session, event, at); + if (!this._sdkHasCapacity(sdk, bytes)) { + this._recordSdkDrop(session, 1, at); + return false; + } + sdk.frames.set(sdk.nextSeq++, { json, bytes, journalEventId }); + sdk.bytes += bytes; + this._sdkRetainedFrames++; + this._sdkRetainedBytes += bytes; + this._wake(session); + return true; + } + + /** Journal replay waits for durable credit rather than dropping the newest state behind a historical backlog. */ + async waitForSdkCapacity(sessionId: string, event: IMissionControlSdkEvent, at: string, token: CancellationToken): Promise { + while (true) { + this._assertLive(); + const session = this._session(sessionId); + const sdk = session.sdk; + if (!sdk) { + throw new Error('SDK sequence range has not been reserved'); + } + const { bytes } = this._sdkCandidate(session, event, at); + if (bytes > this._limits.maxSessionBytes || bytes > this._limits.maxTotalBytes) { + throw new Error('SDK event cannot fit the replay spool'); + } + if (this._sdkHasCapacity(sdk, bytes)) { + return; + } + if (this._sdkCapacityWaiters.has(sessionId)) { + throw new Error('SDK replay already owns a capacity wait'); + } + const waiter = new DeferredPromise(); + this._sdkCapacityWaiters.set(sessionId, waiter); + try { + await raceCancellationError(waiter.p, token); + } finally { + if (this._sdkCapacityWaiters.get(sessionId) === waiter) { + this._sdkCapacityWaiters.delete(sessionId); + } + } + } + } + + private _sdkHasCapacity(sdk: SdkSpool, bytes: number): boolean { + return sdk.droppedSequence === undefined && sdk.frames.size < this._limits.maxSessionFrames + && sdk.bytes + bytes <= this._limits.maxSessionBytes && this._sdkRetainedFrames < this._limits.maxTotalFrames + && this._sdkRetainedBytes + bytes <= this._limits.maxTotalBytes; + } + + private _sdkCandidate(session: SessionSpool, event: IMissionControlSdkEvent, at: string): RetainedFrame { + let json = this._sdkFrameJson(session, session.sdk!.nextSeq, at, event); + const originalBytes = Buffer.byteLength(json); + if (originalBytes + transportAckBytes > this._limits.maxEventBytes) { + json = this._sdkFrameJson(session, session.sdk!.nextSeq, at, { + type: event.type, data: null, _truncated: { reason: 'oversize', bytes: originalBytes }, + }); + } + const bytes = Buffer.byteLength(json); + if (bytes + transportAckBytes > this._limits.maxEventBytes) { + throw new Error('SDK metadata cannot fit the transport ceiling'); + } + return { json, bytes }; + } + + setSdkAcknowledgementHandler(handler: (sessionId: string, journalEventId: string) => void): IDisposable { + if (this._sdkAcknowledged) { + throw new Error('SDK acknowledgement ownership is already assigned'); + } + this._sdkAcknowledged = handler; + return toDisposable(() => { + if (this._sdkAcknowledged === handler) { + this._sdkAcknowledged = undefined; + } + }); + } + + reportSdkSourceLag(sessionId: string, skipped: number): void { + if (!Number.isSafeInteger(skipped) || skipped < 1) { + throw new Error('Invalid SDK source lag'); + } + this._recordSdkDrop(this._session(sessionId), skipped, this._timestamp()); + } + + private _recordSdkDrop(session: SessionSpool, count: number, at: string): void { + const sdk = session.sdk; + if (!sdk || sdk.nextSeq >= sdk.sequenceLimit || !Number.isSafeInteger(sdk.droppedCount + count)) { + throw new Error('SDK truncation marker cannot be sequenced'); + } + if (sdk.droppedSequence === undefined) { + sdk.droppedSequence = sdk.nextSeq++; + this._sdkRetainedFrames++; + this._logService.warn('[MissionControlSessionMirror] SDK metadata was dropped', { environment: this._environmentId, session: session.sessionId }); + } + sdk.droppedCount += count; + if (sdk.droppedSequence <= sdk.publishedSeq) { + return; + } + const previousBytes = sdk.frames.get(sdk.droppedSequence)?.bytes ?? 0; + const json = this._sdkFrameJson(session, sdk.droppedSequence, at, { type: 'session.events_truncated', data: { dropped_count: sdk.droppedCount } }); + const bytes = Buffer.byteLength(json); + if (bytes + transportAckBytes > this._limits.maxEventBytes) { + throw new Error('SDK truncation marker exceeds transport ceiling'); + } + sdk.frames.set(sdk.droppedSequence, { json, bytes }); + sdk.bytes += bytes - previousBytes; + this._sdkRetainedBytes += bytes - previousBytes; + this._wake(session); + } + + /** Ordinary unsent lifecycle markers coalesce separately from the sticky mirror integrity signal. */ + setLifecycle(sessionId: string, kind: Exclude, details?: Record): void { + const session = this._session(sessionId); + if (!['started', 'completed', 'failed', 'aborted'].includes(kind)) { + throw new Error('Invalid Mission Control session lifecycle'); + } + const json = JSON.stringify({ + type: 'event', event: 'sessionLifecycle', dataType: 'json', + data: { environment_id: this._environmentId, session_id: sessionId, kind, at: this._timestamp(), ...(details && { details }) }, + } satisfies MissionControlMirrorEvent); + if (Buffer.byteLength(json) + transportAckBytes > Math.min(maxMetadataBytes * 2, this._limits.maxEventBytes)) { + throw new Error('Mission Control lifecycle capacity exhausted'); + } + session.lifecycleEvent = json; + this._wake(session); + } + + /** Attach only after the connection is ready; disposing an old attachment cannot detach its replacement. */ + attach(sender: MissionControlMirrorSender): IDisposable { + this._assertLive(); + const attachment = { sender }; + this._attachment = attachment; + this._publisherBlocked = false; + this._ready.clear(); + for (const session of this._sessions.values()) { + session.nextToSend = session.acknowledgedSeq + 1; + if (session.sdk) { + session.sdk.nextToSend = session.sdk.acknowledgedSeq + 1; + } + session.failurePending = session.failureEvent !== undefined; + this._wake(session); + } + return toDisposable(() => { + if (this._attachment === attachment) { + this.detach(); + } + }); + } + + detach(): void { + this._attachment = undefined; + this._publisherBlocked = false; + this._ready.clear(); + this._scheduler.cancel(); + } + + resumePublishing(): void { + if (!this._publisherBlocked || !this._attachment || this._store.isDisposed) { + return; + } + this._publisherBlocked = false; + if (this._ready.size > 0 && !this._scheduler.isScheduled()) { + this._scheduler.schedule(); + } + } + + /** Takes the decoded ingest-ack body; validation is atomic and transport acknowledgements never release spool entries. */ + ingestAck(value: unknown): void { + this._assertLive(); + if (!isObject(value) || !isObject(value.watermarks) + || Object.keys(value).some(key => key !== 'watermarks' && key !== 'ack_for_batch') + || (value.ack_for_batch !== undefined && typeof value.ack_for_batch !== 'string') + || Object.keys(value.watermarks).length > this._limits.maxSessions + || Buffer.byteLength(JSON.stringify(value)) > this._limits.maxEventBytes) { + throw new Error('Invalid Mission Control ingest acknowledgement'); + } + const advances: { session: SessionSpool; sequence: number; ns: 'ahp' | 'sdk' }[] = []; + for (const [sessionId, namespaces] of Object.entries(value.watermarks)) { + if (!isSessionId(sessionId) || !isObject(namespaces) || Object.keys(namespaces).length === 0 + || Object.entries(namespaces).some(([ns, seq]) => (ns !== 'ahp' && ns !== 'sdk') || !isSequence(seq))) { + throw new Error('Invalid Mission Control ingest watermark'); + } + const session = this._sessions.get(sessionId); + for (const ns of ['ahp', 'sdk'] as const) { + const spool = ns === 'ahp' ? session : session?.sdk; + const sequence = namespaces[ns]; + if (!session || !spool || !isSequence(sequence)) { + continue; + } + if (sequence > spool.publishedSeq) { + throw new Error('Mission Control ingest watermark exceeds published sequence'); + } + advances.push({ session, sequence, ns }); + } + } + for (const { session, sequence, ns } of advances) { + const spool = ns === 'ahp' ? session : session.sdk!; + if (sequence <= spool.acknowledgedSeq) { + continue; + } + spool.acknowledgedSeq = sequence; + let journalEventId: string | undefined; + for (const [seq, frame] of spool.frames) { + if (seq > sequence) { + break; + } + spool.frames.delete(seq); + spool.bytes -= frame.bytes; + if (ns === 'ahp') { + this._retainedBytes -= frame.bytes; + this._retainedFrames--; + } else { + this._sdkRetainedBytes -= frame.bytes; + this._sdkRetainedFrames--; + journalEventId = frame.journalEventId ?? journalEventId; + } + } + spool.nextToSend = Math.max(spool.nextToSend, sequence + 1); + if (ns === 'sdk' && session.sdk?.droppedSequence !== undefined && session.sdk.droppedSequence <= sequence) { + session.sdk.droppedSequence = undefined; + if (session.sdk.droppedCount) { + this._recordSdkDrop(session, 0, this._timestamp()); + } + } + for (const range of session.backfills) { + if (ns !== 'ahp') { + break; + } + const next = Math.min(range.toSeq + 1, Math.max(range.nextSeq, sequence + 1)); + this._backfillFrames -= next - range.nextSeq; + range.nextSeq = next; + } + this._pruneBackfills(session); + this._wake(session); + if (ns === 'sdk' && journalEventId !== undefined) { + this._sdkAcknowledged?.(session.sessionId, journalEventId); + } + if (ns === 'sdk') { + const waiters = [...this._sdkCapacityWaiters.values()]; + this._sdkCapacityWaiters.clear(); + for (const waiter of waiters) { + waiter.complete(); + } + } + } + } + + /** Replay retained, already-published frames in inclusive sequence order, without rechunking or bypassing credit. */ + backfill(request: IMissionControlMirrorBackfill): void { + const session = this._session(request.session_id); + const count = request.to_seq - request.from_seq + 1; + if (request.kind !== 'backfill_request' || request.environment_id !== this._environmentId || request.ns !== 'ahp' + || !request.request_id || Buffer.byteLength(request.request_id) > maxMetadataBytes + || !isSequence(request.from_seq) || !isSequence(request.to_seq) || count < 1 + || request.from_seq <= session.acknowledgedSeq || request.to_seq > session.publishedSeq) { + throw new Error('Invalid or unavailable Mission Control backfill range'); + } + if (count > this._limits.maxBackfillFrames - this._backfillFrames || this._backfillRequests >= this._limits.maxBackfillRequests) { + throw new Error('Mission Control backfill capacity exhausted'); + } + session.backfills.push({ nextSeq: request.from_seq, toSeq: request.to_seq }); + this._backfillFrames += count; + this._backfillRequests++; + this._wake(session); + } + + getSessionStatus(sessionId: string): IMissionControlMirrorStatus { + const session = this._session(sessionId); + return { + nextSeq: session.nextSeq, publishedSeq: session.publishedSeq, acknowledgedSeq: session.acknowledgedSeq, + retainedFrames: session.frames.size, retainedBytes: session.bytes, failure: session.failure && { ...session.failure }, + }; + } + + get statistics(): { readonly sessions: number; readonly retainedFrames: number; readonly retainedBytes: number; readonly pendingBackfillFrames: number; readonly pendingBackfillRequests: number } { + return { + sessions: this._sessions.size, retainedFrames: this._retainedFrames, retainedBytes: this._retainedBytes, + pendingBackfillFrames: this._backfillFrames, pendingBackfillRequests: this._backfillRequests, + }; + } + + private _frameJson(session: SessionSpool, seq: number, at: string, payload: ChunkEnvelope): string { + return JSON.stringify({ + type: 'event', event: 'sessionEvents', dataType: 'json', + data: { + environment_id: this._environmentId, session_id: session.sessionId, ns: 'ahp', seq, at, + ...(session.project && { project: session.project }), payload, + }, + } satisfies MissionControlMirrorEvent); + } + + private _sdkFrameJson(session: SessionSpool, seq: number, at: string, payload: IMissionControlSdkEvent): string { + return JSON.stringify({ + type: 'event', event: 'sessionEvents', dataType: 'json', + data: { environment_id: this._environmentId, session_id: session.sessionId, ns: 'sdk', seq, at, ...(session.project && { project: session.project }), payload }, + } satisfies MissionControlMirrorEvent); + } + + private _timestamp(): string { + return new Date(this._now()).toISOString(); + } + + private _failureJson(sessionId: string, details: MissionControlMirrorFailure): string { + return JSON.stringify({ + type: 'event', event: 'sessionLifecycle', dataType: 'json', + data: { environment_id: this._environmentId, session_id: sessionId, kind: 'mirror_failed', at: this._timestamp(), details }, + } satisfies MissionControlMirrorEvent); + } + + private _assertLive(): void { + if (this._store.isDisposed) { + throw new Error('Mission Control mirror disposed'); + } + } + + private _session(sessionId: string): SessionSpool { + this._assertLive(); + const session = this._sessions.get(sessionId); + if (!session) { + throw new Error('Mission Control mirror session is not registered'); + } + return session; + } + + private _fail(session: SessionSpool, failure: MissionControlMirrorFailure): void { + if (session.failure) { + return; + } + session.failure = failure; + session.failureEvent = this._failureJson(session.sessionId, failure); + session.failurePending = true; + this._logService.error('[MissionControlSessionMirror] AHP mirror failed', { environment: this._environmentId, session: session.sessionId, ...failure }); + this._wake(session); + } + + private _canSendFrame(session: NamespaceSpool): boolean { + return session.nextToSend < session.nextSeq + && (session.nextToSend <= session.publishedSeq || session.publishedSeq - session.acknowledgedSeq < creditWindow); + } + + private _wake(session: SessionSpool): void { + if (this._attachment && (session.failurePending || session.lifecycleEvent || this._canSendFrame(session) + || (session.sdk && this._canSendFrame(session.sdk)) || session.backfills.length > 0)) { + this._ready.add(session); + if (!this._publisherBlocked && !this._scheduler.isScheduled()) { + this._scheduler.schedule(); + } + } else { + this._ready.delete(session); + } + } + + private _pruneBackfills(session: SessionSpool): void { + for (let index = session.backfills.length - 1; index >= 0; index--) { + if (session.backfills[index].nextSeq > session.backfills[index].toSeq) { + session.backfills.splice(index, 1); + this._backfillRequests--; + } + } + } + + private _flush(): void { + const attachment = this._attachment; + for (let sent = 0; attachment && this._attachment === attachment && sent < this._limits.maxFramesPerFlush; sent++) { + const session = this._ready.values().next().value; + if (!session) { + break; + } + this._ready.delete(session); + const failure = session.failurePending; + const lifecycle = !failure && session.lifecycleEvent; + const normalFrame = !failure && !lifecycle && this._canSendFrame(session); + const sdkFrame = !failure && !lifecycle && !normalFrame && session.sdk && this._canSendFrame(session.sdk) ? session.sdk : undefined; + const range = !failure && !lifecycle && !normalFrame && !sdkFrame ? session.backfills[0] : undefined; + const seq = normalFrame ? session.nextToSend : sdkFrame ? sdkFrame.nextToSend : range?.nextSeq; + const json = failure ? session.failureEvent : lifecycle || (seq !== undefined ? (sdkFrame ?? session).frames.get(seq)?.json : undefined); + if (!json) { + throw new Error('Mission Control mirror spool invariant violated'); + } + try { + const accepted = attachment.sender(JSON.parse(json) as MissionControlMirrorEvent); + if (accepted === false) { + this._publisherBlocked = true; + this._ready.add(session); + break; + } + if (accepted !== undefined) { + throw new Error('Mission Control mirror sender must accept synchronously'); + } + } catch { + if (this._attachment === attachment) { + this.detach(); + } + this._logService.warn('[MissionControlSessionMirror] Transport queue failed; retained frames await reattachment', { environment: this._environmentId, session: session.sessionId }); + break; + } + if (normalFrame && seq !== undefined) { + session.publishedSeq = Math.max(session.publishedSeq, seq); + } else if (sdkFrame && seq !== undefined) { + const firstPublication = seq > sdkFrame.publishedSeq; + sdkFrame.publishedSeq = Math.max(sdkFrame.publishedSeq, seq); + if (firstPublication && sdkFrame.droppedSequence === seq) { + sdkFrame.droppedCount = 0; + } + } + if (this._attachment !== attachment) { + break; + } + if (failure) { + session.failurePending = false; + } else if (lifecycle) { + if (session.lifecycleEvent === lifecycle) { + session.lifecycleEvent = undefined; + } + } else if (normalFrame && seq !== undefined) { + session.nextToSend = Math.max(seq + 1, session.acknowledgedSeq + 1); + } else if (sdkFrame && seq !== undefined) { + sdkFrame.nextToSend = Math.max(seq + 1, sdkFrame.acknowledgedSeq + 1); + } else if (range && range.nextSeq === seq) { + range.nextSeq++; + this._backfillFrames--; + this._pruneBackfills(session); + } + this._wake(session); + } + if (this._attachment && !this._publisherBlocked && this._ready.size > 0 && !this._scheduler.isScheduled()) { + this._scheduler.schedule(); + } + } +} diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 0d957fbfd367e3..efcbfdfe7515bd 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -3,11 +3,18 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { disposableTimeout } from '../../../base/common/async.js'; +import { disposableTimeout, raceCancellationError, raceTimeout } from '../../../base/common/async.js'; +import { CancellationTokenSource } from '../../../base/common/cancellation.js'; +import { lstatSync, realpathSync, statSync } from 'fs'; +import { parseSessionDbUri } from '../common/sessionDbUri.js'; +import { parsePendingEditContentUri } from '../common/pendingEditContentUri.js'; +import { parseGitBlobUri } from './gitDiffContent.js'; +import { dirname, relative, isAbsolute, resolve, sep } from '../../../base/common/path.js'; +import { Schemas } from '../../../base/common/network.js'; import { encodeBase64 } from '../../../base/common/buffer.js'; -import { Emitter } from '../../../base/common/event.js'; +import { Emitter, Event } from '../../../base/common/event.js'; import { isJsonRpcResponse } from '../../../base/common/jsonRpcProtocol.js'; -import { Disposable, DisposableMap, DisposableStore } from '../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, DisposableStore, toDisposable } from '../../../base/common/lifecycle.js'; import { StopWatch } from '../../../base/common/stopwatch.js'; import { hasKey } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; @@ -15,7 +22,8 @@ import { generateUuid } from '../../../base/common/uuid.js'; import { ILogService } from '../../log/common/log.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { AHPFileSystemProvider } from '../common/agentHostFileSystemProvider.js'; -import { getAgentHostClientType } from '../common/agentHostClientInfo.js'; +import { AgentHostClientType, getAgentHostClientType } from '../common/agentHostClientInfo.js'; +import { AgentHostNativeImplementationMetaKey, AgentHostSessionUrisCapabilityMetaKey, supportsAgentHostSessionUris } from '../common/meta/agentHostSessionUrisMeta.js'; import { withSessionInitiator } from '../common/meta/agentSessionInitiatorMeta.js'; import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind, readClientConnectionKind, readClientDevDeviceId, readClientMachineId, readClientTelemetryLevel, type IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; import { AgentSession, type IAgentCreateChatRequestOptions, type IMcpNotification } from '../common/agent.js'; @@ -32,7 +40,7 @@ import { isActionEnvelopeRelevantToSubscriptionUris } from '../common/state/agen import { IS_CLIENT_DISPATCHABLE } from '../common/state/protocol/action-origin.generated.js'; import { ChatSourceKind } from '../common/state/protocol/channels-chat/commands.js'; import type { CommandMap } from '../common/state/protocol/messages.js'; -import { ActionEnvelope, ActionType, INotification, isAnnotationsAction, isAutomationAction, isAutomationRunAction, isChangesetAction, isChatAction, isSessionAction, isTerminalAction, type ChatAction, type ClientAnnotationsAction, type ClientAutomationAction, type ClientAutomationRunAction, type ClientChangesetAction, type IRootConfigChangedAction, type SessionAction, type TerminalAction } from '../common/state/sessionActions.js'; +import { ActionEnvelope, ActionType, INotification, isAnnotationsAction, isAutomationAction, isAutomationRunAction, isChangesetAction, isChatAction, isSessionAction, isTerminalAction } from '../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../common/state/protocol/version/registry.js'; import { negotiateProtocolVersion } from '../common/state/protocol/version/negotiation.js'; import { VSCODE_UPGRADE_METHOD, type UnsupportedProtocolVersionErrorDataEx } from '../common/state/protocolUpgrade.js'; @@ -56,8 +64,9 @@ import { type IStateSnapshot, type SubscribeResult, type ListSessionsResult, + type DispatchActionParams, } from '../common/state/sessionProtocol.js'; -import { isAhpAutomationCatalogChannel, isAhpResourceWatchChannel, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildDefaultChatUri, isAhpChatChannel, parseChatUri, parseRequiredSessionUriFromChatUri, withSessionStatusFlag, type ISessionWithDefaultChat, type SessionState } from '../common/state/sessionState.js'; +import { isAhpRootChannel, isAhpAutomationCatalogChannel, isAhpResourceWatchChannel, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildDefaultChatUri, isAhpChatChannel, parseChatUri, parseRequiredSessionUriFromChatUri, withSessionStatusFlag, type ISessionWithDefaultChat, type SessionState } from '../common/state/sessionState.js'; import type { IProtocolServer, IProtocolTransport } from '../common/state/sessionTransport.js'; import { IAgentHostManagedSettingsService } from './agentHostManagedSettingsService.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; @@ -86,6 +95,8 @@ const REPLAY_BUFFER_CAPACITY = 1000; const CLIENT_TOOL_CALL_DISCONNECT_TIMEOUT = 30_000; +const CLIENT_ACTION_ACKNOWLEDGEMENT_TIMEOUT = 30_000; + /** Client actions whose state transition has no corresponding host-side behavior. */ const UNSUPPORTED_CLIENT_ACTION_TYPES: ReadonlySet = new Set([ ActionType.ChatWorkingDirectorySet, @@ -137,6 +148,16 @@ function isParamsObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +function isDispatchActionParams(value: unknown): value is DispatchActionParams { + return isParamsObject(value) && typeof value.channel === 'string' && Number.isSafeInteger(value.clientSeq) + && isParamsObject(value.action) && typeof value.action.type === 'string'; +} + +function isHandledClientAction(action: DispatchActionParams['action']): action is Parameters[1] { + return IS_CLIENT_DISPATCHABLE[action.type] === true + && (isSessionAction(action) || isChatAction(action) || isTerminalAction(action) || isChangesetAction(action) || isAnnotationsAction(action) || isAutomationAction(action) || isAutomationRunAction(action) || action.type === ActionType.RootConfigChanged); +} + /** * Returns the `channel` URI carried on a request's params when it is an * `mcp://` channel — the AHP routing envelope for raw MCP requests @@ -220,6 +241,7 @@ type ChannelSubscription = * Represents a connected protocol client with its subscription state. */ interface IConnectedClient { + readonly legacySessionUris: boolean; devContainers?: DevContainerAgentHostProtocol; readonly clientId: string; readonly clientInfo: Implementation | undefined; @@ -274,6 +296,7 @@ interface IActiveClientRecord { } interface IGraceClientRecord { + readonly legacySessionUris?: boolean; readonly state: 'grace'; readonly seenConnection: boolean; readonly clientInfo: Implementation | undefined; @@ -338,6 +361,11 @@ export interface IProtocolServerConfig { * methods such as `vscode/removeSessionArtifact` are always available. */ readonly allowExtensionMethods?: boolean; + /** Restricts experimental relay clients to explicitly granted local roots. */ + readonly relayRoots?: readonly string[]; + readonly relayRootMeta?: Record; + /** Locally known workspace/content roots exposed by the Mission Control host. */ + readonly relayResourceRoots?: (readOnly: boolean) => readonly string[]; /** * Characters that, when typed in a {@link UserMessage} input, SHOULD * cause the client to issue a `completions` request. Announced to @@ -476,12 +504,21 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien const result = this._handleInitialize(msg.params, transport, disposables); client = result.client; const sendInitializeResult = (response: IAgentHostExtensionInitializeResult) => { - transport.send(jsonRpcSuccess(msg.id, response)); + if (!disposables.isDisposed) { + transport.send(jsonRpcSuccess(msg.id, response)); + } }; if (result.response instanceof Promise) { this._trackRequest(result.response).then( sendInitializeResult, - err => transport.send(jsonRpcErrorFrom(msg.id, err)), + err => { + if (client === result.client) { + client = undefined; + } + if (!disposables.isDisposed) { + transport.send(jsonRpcErrorFrom(msg.id, err)); + } + }, ); } else { sendInitializeResult(result.response); @@ -493,16 +530,26 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien } if (!client && msg.method === 'reconnect') { let responsePromise: Promise; + let reconnectingClient: IConnectedClient; try { const result = this._handleReconnect(msg.params, transport, disposables); - const reconnectClient = result.client; - client = reconnectClient; + client = result.client; + reconnectingClient = result.client; responsePromise = this._trackRequest(result.responsePromise); responsePromise.then( response => { - transport.send(jsonRpcSuccess(msg.id, response)); + if (!disposables.isDisposed) { + transport.send(jsonRpcSuccess(msg.id, response)); + } + }, + err => { + if (client === reconnectingClient) { + client = undefined; + } + if (!disposables.isDisposed) { + transport.send(jsonRpcErrorFrom(msg.id, err)); + } }, - err => transport.send(jsonRpcErrorFrom(msg.id, err)), ); } catch (err) { transport.send(jsonRpcErrorFrom(msg.id, err)); @@ -515,6 +562,10 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien // the client's protocol version was rejected, so the client // never managed to complete the handshake. if ((msg.method as string) === VSCODE_UPGRADE_METHOD) { + if (transport.relayClientId !== undefined) { + transport.send(jsonRpcError(msg.id, JsonRpcErrorCodes.MethodNotFound, 'Host management is unavailable to relay clients')); + return; + } this._handleVscodeUpgrade(msg.id, transport); return; } @@ -526,7 +577,14 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien this._handleRequest(client, msg.method, msg.params, msg.id); } else if (isJsonRpcNotification(msg)) { this._logService.trace(`[ProtocolServer] notification: method=${msg.method}`); + if (this._config.relayRoots && msg.method !== 'unsubscribe') { + return; + } if ((msg as { method: string }).method === 'setClientManagedSettingsPermissions') { + if (transport.relayClientId !== undefined) { + this._logService.warn('[ProtocolServer] Relay clients cannot change host managed permissions'); + return; + } if (client) { const permissions = ((msg as { params?: { permissions?: unknown } }).params)?.permissions; if (isManagedSettingsPermissions(permissions)) { @@ -546,28 +604,9 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien break; case 'dispatchAction': if (client) { - this._logService.trace(`[ProtocolServer] dispatchAction: ${JSON.stringify(msg.params.action.type)}`); - const action = msg.params.action as SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | ClientAutomationAction | ClientAutomationRunAction | IRootConfigChangedAction; - const channel = msg.params.channel; - // Rejected actions are echoed so optimistic clients roll back. - if (IS_CLIENT_DISPATCHABLE[action.type] !== true) { - this._logService.warn(`[ProtocolServer] rejecting server-only client action: ${action.type}`); - this._stateManager.rejectClientAction( - channel, - action, - { clientId: client.clientId, clientSeq: msg.params.clientSeq }, - `Server-only action: ${action.type}`, - ); - } else if (UNSUPPORTED_CLIENT_ACTION_TYPES.has(action.type)) { - this._logService.warn(`[ProtocolServer] rejecting unsupported client action: ${action.type}`); - this._stateManager.rejectClientAction( - channel, - action, - { clientId: client.clientId, clientSeq: msg.params.clientSeq }, - `Unsupported action: ${action.type}`, - ); - } else if (isSessionAction(action) || isChatAction(action) || isTerminalAction(action) || isChangesetAction(action) || isAnnotationsAction(action) || isAutomationAction(action) || isAutomationRunAction(action) || action.type === ActionType.RootConfigChanged) { - this._agentService.dispatchAction(channel, action, client.clientId, msg.params.clientSeq, client.telemetryContext); + const pending = this._dispatchClientAction(client, msg.params); + if (pending) { + pending.catch(error => this._logService.error('[ProtocolServer] Notification dispatch failed', error)); } } break; @@ -603,6 +642,7 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien this._clients.set(client.clientId, { state: 'grace', seenConnection: true, + legacySessionUris: client.legacySessionUris, clientInfo: record.clientInfo, telemetryContext: client.telemetryContext, protocolVersion: client.protocolVersion, @@ -628,6 +668,10 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien transport: IProtocolTransport, disposables: DisposableStore, ): { client: IConnectedClient; response: IAgentHostExtensionInitializeResult | Promise } { + this._verifyRelayClient(transport, params.clientId); + if (this._config.relayRoots && params.initialSubscriptions?.length) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'Relay subscriptions are not available in this prototype'); + } const offered = Array.isArray(params.protocolVersions) ? params.protocolVersions : []; this._logService.info(`[ProtocolServer] Initialize: clientId=${params.clientId}, protocolVersions=[${offered.join(', ')}]`); @@ -656,6 +700,7 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien const initializationDisposables = disposables.add(new DisposableStore()); const telemetryContext = this._createClientTelemetryContext(params.clientInfo, params._meta, transport); const client: IConnectedClient = { + legacySessionUris: getAgentHostClientType(params.clientInfo) !== AgentHostClientType.Unknown && !supportsAgentHostSessionUris(params), clientId: params.clientId, clientInfo: params.clientInfo, telemetryContext, @@ -677,6 +722,9 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien const pendingSnapshots: Promise[] = []; if (params.initialSubscriptions) { for (const uri of params.initialSubscriptions) { + if (transport.relayAuthenticated === false && !isAhpRootChannel(uri)) { + continue; + } const snapshot = this._addInitialSubscription(client, uri.toString()); if (snapshot instanceof Promise) { pendingSnapshots.push(snapshot.then(value => { @@ -710,25 +758,27 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien const response: IAgentHostExtensionInitializeResult = { protocolVersion: negotiated, serverSeq: this._stateManager.serverSeq, - _meta: getAgentHostExtensionInitializeResultMeta( - !!this._agentService.removeSessionArtifact, - !!client.devContainers, - this._otelService?.diagnosticsEnabled, - !!this._agentService.importSession, - ), - snapshots, + _meta: this._config.relayRoots || this._config.relayRootMeta ? { + [AgentHostNativeImplementationMetaKey]: true, + [AgentHostSessionUrisCapabilityMetaKey]: true, + ...transport.relayHandshakeMeta, + } : { + ...getAgentHostExtensionInitializeResultMeta(!!this._agentService.removeSessionArtifact, !!client.devContainers, this._otelService?.diagnosticsEnabled, !!this._agentService.importSession), + ...transport.relayHandshakeMeta, + }, + snapshots: snapshots.map(snapshot => this._projectRelayRootSnapshot(client, snapshot)), defaultDirectory: this._config.defaultDirectory, completionTriggerCharacters: this._config.completionTriggerCharacters ? [...this._config.completionTriggerCharacters] : undefined, terminalCommandPrefix: this._config.terminalCommandPrefix, telemetry: this._config.otlpLogEmitter ? { logs: OTLP_LOGS_CHANNEL_TEMPLATE } : undefined, - automations: this._agentService.automationCapabilities, + automations: this._config.relayRoots ? undefined : this._agentService.automationCapabilities, }; return { client, response: pendingSnapshots.length === 0 ? response : Promise.all(pendingSnapshots).then(() => ({ ...response, serverSeq: this._stateManager.serverSeq, - snapshots: snapshots.map(snapshot => this._stateManager.getSnapshot(snapshot.resource) ?? snapshot), + snapshots: snapshots.map(snapshot => this._projectRelayRootSnapshot(client, this._getSnapshot(snapshot.resource) ?? snapshot)), })).catch(error => { this._rollbackFailedInitialization(client, previousRecord); throw error; @@ -757,6 +807,10 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien * remain subscribed even when their snapshot has not materialized yet. */ private _addInitialSubscription(client: IConnectedClient, channel: string): IStateSnapshot | undefined | Promise { + if (!this._isChannelVisible(client, channel)) { + this._logService.info(`[ProtocolServer] Legacy VS Code client cannot subscribe to ${channel}`); + return undefined; + } const sub = classifyChannel(channel); if (!sub) { return undefined; @@ -779,7 +833,7 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien return undefined; }); } - const snapshot = this._stateManager.getSnapshot(channel); + const snapshot = this._getSnapshot(channel); client.subscriptions.set(sub.uri, sub); this._agentService.addSubscriber(URI.parse(sub.uri), client.clientId); this._clearClientToolCallDisconnectTimeout(client.clientId, sub.uri); @@ -824,14 +878,18 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien }); } - private async _subscribeStateChannel(channel: string, clientId: string, isActive?: () => boolean): Promise { + private async _subscribeStateChannel(channel: string, client: IConnectedClient, isActive?: () => boolean): Promise { + const clientId = client.clientId; + if (!this._isChannelVisible(client, channel)) { + throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Resource not available to this client: ${channel}`); + } if (!isAhpAutomationCatalogChannel(channel)) { return this._agentService.subscribe(URI.parse(channel), clientId, isActive); } if (isActive && !isActive()) { throw new Error(`Subscription cancelled: ${channel}`); } - const snapshot = this._stateManager.getSnapshot(channel); + const snapshot = this._getSnapshot(channel); if (!snapshot) { throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Automation catalogue is unavailable: ${channel}`); } @@ -873,11 +931,18 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien transport: IProtocolTransport, disposables: DisposableStore, ): { client: IConnectedClient; responsePromise: Promise } { + this._verifyRelayClient(transport, params.clientId); + if (this._config.relayRoots && params.subscriptions?.length) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'Relay subscriptions are not available in this prototype'); + } this._logService.info(`[ProtocolServer] Reconnect: clientId=${params.clientId}, lastSeenSeq=${params.lastSeenServerSeq}`); const existingRecord = this._clients.get(params.clientId); if (!existingRecord) { throw new ProtocolError(AhpErrorCodes.NotFound, `Reconnect client not found: ${params.clientId}`); } + if (transport.relayAuthenticated === false) { + throw new ProtocolError(AhpErrorCodes.NotFound, 'Relay reconnect requires fresh initialization and identity authentication'); + } this._applyClientTelemetryLevel(params._meta); // Synchronously install the client so messages arriving on this transport @@ -892,6 +957,7 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien const isReconnect = this._clientConnections.hasSeenClient(params.clientId); const initializationDisposables = disposables.add(new DisposableStore()); const client: IConnectedClient = { + legacySessionUris: getAgentHostClientType(existingRecord.clientInfo) !== AgentHostClientType.Unknown && !supportsAgentHostSessionUris(params), clientId: params.clientId, clientInfo: existingRecord.clientInfo, telemetryContext: this._createClientTelemetryContext(existingRecord.clientInfo, params._meta, transport, priorTelemetryContext?.connectionKind), @@ -921,7 +987,14 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien // channel still owes a baseline. const canReplay = params.lastSeenServerSeq >= oldestBuffered && !this._hasBaselineDebt(params.clientId, params.subscriptions); - const responsePromise = this._restoreReconnectSubscriptions(client, params, canReplay); + const responsePromise = this._restoreReconnectSubscriptions(client, params, canReplay).then(result => ( + transport.relayHandshakeMeta && typeof result === 'object' && result !== null + ? { ...result, _meta: transport.relayHandshakeMeta } + : result + )).catch(error => { + this._rollbackFailedInitialization(client, existingRecord); + throw error; + }); client.telemetryConnectionActive = true; const counts = this._clientConnections.getConnectionCounts(params.clientId); @@ -1031,7 +1104,7 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien try { const snapshot = await this._subscribeStateChannel( key, - client.clientId, + client, () => client.subscriptions.get(classified.uri) === pendingSubscription, ); if (client.subscriptions.get(classified.uri) !== pendingSubscription) { @@ -1082,7 +1155,7 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien if (subscription?.kind !== ChannelKind.State) { return snapshot; } - const refreshed = this._stateManager.getSnapshot(subscription.uri); + const refreshed = this._getSnapshot(subscription.uri); if (refreshed) { // Key off the subscription, not the snapshot's echoed resource, // so the debt entry recorded under the same key is really cleared. @@ -1128,7 +1201,7 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien record.disconnectTimeouts.set('managed-settings', disposableTimeout(() => { record.disconnectTimeouts.deleteAndDispose('managed-settings'); this._managedSettingsService.removeClient(this._managedSettingsContributionId(clientId)); - }, CLIENT_TOOL_CALL_DISCONNECT_TIMEOUT)); + }, Math.max(0, CLIENT_TOOL_CALL_DISCONNECT_TIMEOUT - (Date.now() - record.lastSeenAt)))); } for (const session of this._stateManager.getSessionUris()) { const state = this._stateManager.getSessionState(session); @@ -1300,7 +1373,9 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien } if (record.connections.length === 0) { if (previousRecord?.state === 'grace') { - this._clients.set(client.clientId, previousRecord); + previousRecord.disconnectTimeouts.dispose(); + this._clients.set(client.clientId, { ...previousRecord, disconnectTimeouts: new DisposableMap() }); + this._handleClientDisconnected(client.clientId); } else { this._clients.delete(client.clientId); this._baselineDebt.delete(client.clientId); @@ -1394,6 +1469,13 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien && record.connections.some(connection => connection.telemetryConnectionActive && this._isLocalClientConnection(connection)); } + usesLegacySessionUris(clientId: string): boolean { + const record = this._clients.get(clientId); + return record?.state === 'active' + ? this._getActiveClientFromRecord(record)?.legacySessionUris === true + : record?.legacySessionUris === true; + } + getConnectedClientTransportCounts(): ReadonlyMap { const result = new Map(); for (const [clientId, record] of this._clients) { @@ -1606,7 +1688,7 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien try { const snapshot = await this._subscribeStateChannel( params.channel, - client.clientId, + client, () => client.subscriptions.get(classified.uri) === pendingSubscription, ); if (client.subscriptions.get(classified.uri) !== pendingSubscription) { @@ -1618,7 +1700,7 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien // `IStateSnapshot` is widened with `ChatState` (see sessionProtocol.ts); // the generated wire `Snapshot` union does not list it yet. The value // is JSON over the wire, so narrowing at this boundary is safe. - return { snapshot: snapshot as SubscribeResult['snapshot'] }; + return { snapshot: this._projectRelayRootSnapshot(client, snapshot) as SubscribeResult['snapshot'] }; } catch (err) { // Losing request ownership cancels the subscription, not the resource. if (client.subscriptions.get(classified.uri) !== pendingSubscription) { @@ -1634,6 +1716,13 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien } }, createSession: async (_client, params) => { + this._requireRelayMutation(_client); + if (_client.transport.relayClientId !== undefined && this._config.relayResourceRoots && (!params.workingDirectories?.length || !params.workingDirectories.every(directory => this._isGrantedRelayResource(directory, false)))) { + throw new ProtocolError(AhpErrorCodes.PermissionDenied, 'Session creation requires a host workspace grant'); + } + if (this._config.relayRoots && (!params.workingDirectories?.length || !params.workingDirectories.every(directory => this._isGrantedRelayDirectory(directory)))) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'Working directory is not an explicitly granted project root'); + } let createdSession: URI; // If the client eagerly claimed the active client role, validate // the clientId matches the connection before forwarding. @@ -1722,46 +1811,56 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien resourceWrite: async (_client, params) => { return this._agentService.resourceWrite(params); }, - listSessions: async () => { + listSessions: async (client) => { const sessions = await this._agentService.listSessions(); - const items = sessions.map(s => { - const provider = AgentSession.provider(s.session); - if (!provider) { - throw new Error(`Agent session URI has no provider scheme: ${s.session.toString()}`); - } - return { - resource: s.session.toString(), - provider, - title: s.summary ?? 'Session', - status: s.status ?? SessionStatus.Idle, - activity: s.activity, - createdAt: new Date(s.startTime).toISOString(), - modifiedAt: new Date(s.modifiedTime).toISOString(), - ...(s.project ? { project: { uri: s.project.uri.toString(), displayName: s.project.displayName } } : {}), - workingDirectories: s.workingDirectories?.map(d => d.toString()), - changes: s.changes, - chats: s.chats?.map(chat => ({ - resource: chat.chat.toString(), - title: chat.summary ?? '', - origin: chat.origin, - ...(chat.interactivity !== undefined ? { interactivity: chat.interactivity } : {}), - ...(chat.isRead !== undefined ? { - status: withSessionStatusFlag( - withSessionStatusFlag(SessionStatus.Idle, SessionStatus.IsArchived, chat.archived === true), - SessionStatus.IsRead, - chat.isRead, - ), - } : {}), - ...(chat.archived === true ? { archived: true } : {}), - ...(chat.changes !== undefined ? { changes: chat.changes } : {}), - })), - defaultChat: s.chats?.find(chat => chat.kind === 'default')?.chat.toString(), - // `_meta` carries durable host provenance, including session kind - // and provider-native discovery provenance. - ...(s._meta !== undefined ? { _meta: s._meta } : {}), - } satisfies ListSessionsResult['items'][number]; - }); - return { items: this._stateManager.prepareSessionSummariesForListing(items) }; + const items = sessions.filter(s => this._isSessionVisible(client, s.session.toString()) + && (!this._config.relayRoots || (s.workingDirectories?.length && s.workingDirectories.every(directory => this._isGrantedRelayDirectory(directory.toString()))))).map(s => { + const provider = s.provider ?? AgentSession.provider(s.session); + if (!provider) { + throw new Error(`Agent session URI has no provider scheme: ${s.session.toString()}`); + } + return { + resource: s.session.toString(), + provider, + title: s.summary ?? 'Session', + status: s.status ?? SessionStatus.Idle, + activity: s.activity, + createdAt: new Date(s.startTime).toISOString(), + modifiedAt: new Date(s.modifiedTime).toISOString(), + ...(s.project ? { project: { uri: s.project.uri.toString(), displayName: s.project.displayName } } : {}), + workingDirectories: s.workingDirectories?.map(d => d.toString()), + changes: s.changes, + chats: s.chats?.map(chat => ({ + resource: chat.chat.toString(), + title: chat.summary ?? '', + origin: chat.origin, + ...(chat.interactivity !== undefined ? { interactivity: chat.interactivity } : {}), + ...(chat.isRead !== undefined ? { + status: withSessionStatusFlag( + withSessionStatusFlag(SessionStatus.Idle, SessionStatus.IsArchived, chat.archived === true), + SessionStatus.IsRead, + chat.isRead, + ), + } : {}), + ...(chat.archived === true ? { archived: true } : {}), + ...(chat.changes !== undefined ? { changes: chat.changes } : {}), + })), + defaultChat: s.chats?.find(chat => chat.kind === 'default')?.chat.toString(), + // `_meta` carries durable host provenance, including session kind + // and provider-native discovery provenance. + ...(s._meta !== undefined ? { _meta: s._meta } : {}), + } satisfies ListSessionsResult['items'][number]; + }); + const prepared = this._stateManager.prepareSessionSummariesForListing(items); + return { + items: this._config.relayRoots + ? prepared.filter(item => item.workingDirectories?.length && item.workingDirectories.every(directory => this._isGrantedRelayDirectory(directory))) + .map(item => ({ + ...item, + ...(item.project && !this._isGrantedRelayDirectory(item.project.uri) ? { project: undefined } : {}), + })) + : prepared, + }; }, listAutomationTriggerDefinitions: async (_client, params) => { return this._agentService.listAutomationTriggerDefinitions(params); @@ -1836,7 +1935,14 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien return {}; }, authenticate: async (_client, params) => { - const result = await this._agentService.authenticate(params); + const authentication = _client.transport.relayAuthenticate ? await _client.transport.relayAuthenticate(params) : params; + if (_client.disposables.isDisposed) { + throw new ProtocolError(AHP_AUTH_REQUIRED, 'Relay connection ended during authentication'); + } + if (_client.transport.relayPassive) { + return {}; + } + const result = await this._agentService.authenticate(authentication); if (!result.authenticated) { throw new ProtocolError(AHP_AUTH_REQUIRED, `Authentication failed for resource: ${params.resource}`); } @@ -1898,17 +2004,64 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien } private _handleRequest(client: IConnectedClient, method: string, params: unknown, id: number): void { + if (client.transport.relayAuthenticated === false && method !== 'authenticate') { + client.transport.send(jsonRpcError(id, AHP_AUTH_REQUIRED, 'Relay identity authentication is required')); + return; + } + try { + this._requireRelayResourceAccess(client, method, params); + } catch (error) { + this._logService.warn('[ProtocolServer] Relay resource access denied', error); + client.transport.send(jsonRpcErrorFrom(id, error)); + return; + } + if (method === 'dispatchAction' || method === 'unsubscribe') { + this._trackRequest(this._handleLegacyNotificationRequest(client, method, params)).then( + result => { + if (!client.disposables.isDisposed) { + client.transport.send(jsonRpcSuccess(id, result)); + } + }, + error => { + if (client.disposables.isDisposed) { + return; + } + this._logService.error(`[ProtocolServer] Request '${method}' failed`, error); + client.transport.send(jsonRpcErrorFrom(id, error)); + }, + ); + return; + } + if (client.transport.relayPassive && !new Set([ + 'authenticate', 'subscribe', 'listSessions', 'listChats', 'listModels', 'listAgents', + 'resolveSessionConfig', 'sessionConfigCompletions', 'completions', 'fetchTurns', + 'resourceList', 'resourceRead', 'resourceResolve', + ]).has(method)) { + client.transport.send(jsonRpcError(id, JsonRpcErrorCodes.InvalidRequest, 'Passive relay connections cannot mutate state')); + return; + } + if (this._config.relayRoots && method !== 'listSessions' && method !== 'createSession') { + client.transport.send(jsonRpcError(id, JsonRpcErrorCodes.MethodNotFound, `Method not available to relay clients: ${method}`)); + return; + } const handler = this._requestHandlers.hasOwnProperty(method) ? this._requestHandlers[method as RequestMethod] : undefined; if (handler) { this._trackRequest((handler as (client: IConnectedClient, params: unknown) => Promise)(client, params)).then(result => { + if (client.disposables.isDisposed) { + return; + } this._logService.trace(`[ProtocolServer] Request '${method}' id=${id} succeeded`); client.transport.send(jsonRpcSuccess(id, result ?? null)); }).catch(err => { + if (client.disposables.isDisposed) { + return; + } if (err instanceof SubscriptionCancelledError) { this._logService.trace(`[ProtocolServer] Request '${method}' id=${id} cancelled`, err.message); } else if (shouldLogFailedRequest(method, params, err)) { this._logService.error(`[ProtocolServer] Request '${method}' failed`, err); } + client.transport.send(jsonRpcErrorFrom(id, err)); }); return; @@ -1950,6 +2103,262 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien client.transport.send(jsonRpcError(id, JsonRpcErrorCodes.MethodNotFound, `Method not found: ${method}`)); } + private _dispatchClientAction(client: IConnectedClient, params: DispatchActionParams): void | Promise { + this._logService.trace(`[ProtocolServer] dispatchAction: ${JSON.stringify(params.action.type)}`); + const action = params.action; + const origin = { clientId: client.clientId, clientSeq: params.clientSeq }; + let rejection: string | undefined; + if (client.transport.relayAuthenticated === false) { + rejection = 'Relay identity authentication is required'; + } else if (client.transport.relayClientId !== undefined && action.type === ActionType.RootConfigChanged) { + rejection = 'Relay clients cannot change host configuration'; + } else if (client.transport.relayPassive) { + rejection = 'Passive relay connections cannot mutate state'; + } else if (IS_CLIENT_DISPATCHABLE[action.type] !== true) { + rejection = `Server-only action: ${action.type}`; + } else if (UNSUPPORTED_CLIENT_ACTION_TYPES.has(action.type)) { + rejection = `Unsupported action: ${action.type}`; + } + if (!rejection && client.transport.relayClientId !== undefined && this._config.relayResourceRoots && action.type === ActionType.SessionWorkingDirectorySet) { + try { + if (!this._isGrantedRelayResource(action.directory, false)) { + rejection = 'Working directory is outside the host workspace grants'; + } + } catch (error) { + this._logService.warn('[ProtocolServer] Invalid relay working-directory resource', error); + rejection = 'Working-directory resource could not be authorized'; + } + } + if (rejection) { + this._logService.warn(`[ProtocolServer] rejecting client action: ${rejection}`); + this._stateManager.rejectClientAction(params.channel, action, origin, rejection); + } else if (isHandledClientAction(action)) { + return this._agentService.dispatchAction(params.channel, action, client.clientId, params.clientSeq, client.telemetryContext); + } + } + + /** + * Compatibility workaround for clients that send AHP's dispatchAction and unsubscribe notifications as requests. + * Dispatch replies acknowledge the authoritative echo, not a separate application path. + */ + private async _handleLegacyNotificationRequest(client: IConnectedClient, method: 'dispatchAction' | 'unsubscribe', params: unknown): Promise<{ serverSeq: number } | null> { + if (!isParamsObject(params) || typeof params.channel !== 'string') { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'A channel is required'); + } + if (method === 'unsubscribe') { + this._removeSubscription(client, params.channel); + return null; + } + if (this._config.relayRoots) { + throw new ProtocolError(JsonRpcErrorCodes.MethodNotFound, 'Action dispatch is unavailable to restricted relay clients'); + } + if (!isDispatchActionParams(params)) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'A client sequence and action type are required'); + } + return this._handleDispatchActionRequest(client, params); + } + + private async _handleDispatchActionRequest(client: IConnectedClient, params: DispatchActionParams): Promise<{ serverSeq: number }> { + if (!this._stateManager.getSnapshot(params.channel)) { + return { serverSeq: -1 }; + } + const listeners = new DisposableStore(); + let serverSeq: number | undefined; + const cancellation = listeners.add(new CancellationTokenSource()); + const cancelOnDisconnect = client.disposables.add(toDisposable(() => cancellation.cancel())); + try { + const envelopes = Event.any(this._stateManager.onDidEmitEnvelope, this._stateManager.onDidRejectClientAction); + listeners.add(Event.once(Event.filter(envelopes, envelope => + envelope.channel === params.channel && envelope.origin?.clientId === client.clientId && envelope.origin.clientSeq === params.clientSeq, + ))(envelope => { serverSeq = envelope.serverSeq; })); + const acknowledgement = Promise.resolve(this._dispatchClientAction(client, params)).then(() => { + if (serverSeq === undefined) { + throw new ProtocolError(JSON_RPC_INTERNAL_ERROR, 'Action completed without an authoritative acknowledgement'); + } + return { serverSeq }; + }); + const result = await raceTimeout(raceCancellationError(acknowledgement, cancellation.token), CLIENT_ACTION_ACKNOWLEDGEMENT_TIMEOUT); + if (result === undefined) { + throw new ProtocolError(JSON_RPC_INTERNAL_ERROR, 'Action acknowledgement timed out; outcome is unknown'); + } + return result; + } finally { + client.disposables.delete(cancelOnDisconnect); + listeners.dispose(); + } + } + + private _verifyRelayClient(transport: IProtocolTransport, clientId: string): void { + if (transport.relayClientId !== undefined && transport.relayClientId !== clientId) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'Client ID does not match relay lane'); + } + if (transport.relayClientId !== undefined && !this._clients.has(clientId) && this._clientConnections.hasSeenClient(clientId)) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'Relay client ID collides with another host connection'); + } + } + + private _requireRelayResourceAccess(client: IConnectedClient, method: string, params: unknown): void { + if (client.transport.relayClientId === undefined || !this._config.relayResourceRoots) { + return; + } + const fields = method === 'resourceCopy' || method === 'resourceMove' ? ['source', 'destination'] + : method.startsWith('resource') || method === 'createResourceWatch' ? ['uri'] + : method === 'createTerminal' ? ['cwd'] : []; + for (const field of fields) { + const value = isParamsObject(params) ? params[field] ?? (field === 'cwd' ? this._config.defaultDirectory : undefined) : undefined; + const readOnly = ['resourceRead', 'resourceList', 'resourceResolve', 'createResourceWatch'].includes(method) || (method === 'resourceCopy' && field === 'source') || (method === 'resourceRequest' && isParamsObject(params) && params.write !== true); + if (typeof value !== 'string' || !this._isGrantedRelayResource(value, readOnly)) { + throw new ProtocolError(AhpErrorCodes.PermissionDenied, 'Resource is outside the host workspace grants'); + } + if (field === 'cwd') { + let directory = false; + try { + directory = statSync(URI.parse(value).fsPath).isDirectory(); + } catch (error) { + if (!isParamsObject(error) || (error.code !== 'ENOENT' && error.code !== 'ENOTDIR')) { + throw error; + } + } + if (!directory) { + throw new ProtocolError(AhpErrorCodes.PermissionDenied, 'Terminal cwd must be an existing granted directory'); + } + } + } + } + + private _isGrantedRelayResource(resource: string, readOnly: boolean): boolean { + const uri = URI.parse(resource, true); + if (uri.scheme !== Schemas.file) { + const gitContent = readOnly ? parseGitBlobUri(resource) : undefined; + if (gitContent && (!gitContent.sha || gitContent.sha.startsWith('-'))) { + return false; + } + const pendingContent = readOnly ? parsePendingEditContentUri(resource) : undefined; + const content = readOnly ? parseSessionDbUri(resource) ?? gitContent ?? pendingContent : undefined; + if (!content) { + return false; + } + const boundChat = isAhpChatChannel(content.sessionUri) ? parseChatUri(content.sessionUri) : undefined; + const session = isAhpChatChannel(content.sessionUri) ? boundChat?.session : content.sessionUri; + if (!session || (!this._stateManager.getSessionSummary(session) && !this._stateManager.getSurfacedSessionSummary(session))) { + return false; + } + if (pendingContent) { + // The memory provider keys by path, not authority. Require the exact + // reference published by an owning chat's pending tool, not just its owner. + // Worker previews retain their parent's storage URI. + return this._stateManager.getSessionState(session)?.chats.some(chat => + this._stateManager.getChatState(chat.resource)?.activeTurn?.responseParts.some(part => + part.kind === ResponsePartKind.ToolCall && part.toolCall.status === ToolCallStatus.PendingConfirmation + && part.toolCall.edits?.items.some(edit => + (edit.before?.content.uri && URI.parse(edit.before.content.uri).toString() === uri.toString()) + || (edit.after?.content.uri && URI.parse(edit.after.content.uri).toString() === uri.toString()) + ) + ) + ) === true; + } + return true; + } + let ancestor = uri.fsPath; + let canonical: string; + while (true) { + try { + const resolvedAncestor = realpathSync(ancestor); + if (ancestor !== uri.fsPath && !statSync(resolvedAncestor).isDirectory()) { + return false; + } + canonical = resolve(resolvedAncestor, relative(ancestor, uri.fsPath)); + break; + } catch (error) { + if (isParamsObject(error) && error.code === 'ENOTDIR') { + return false; + } + if (!isParamsObject(error) || error.code !== 'ENOENT' || dirname(ancestor) === ancestor) { + throw error; + } + try { + if (lstatSync(ancestor).isSymbolicLink()) { + return false; + } + } catch (statError) { + if (!isParamsObject(statError) || statError.code !== 'ENOENT') { + throw statError; + } + } + ancestor = dirname(ancestor); + } + } + return this._config.relayResourceRoots?.(readOnly).some(root => { + try { + const relativePath = relative(realpathSync(root), canonical); + return !isAbsolute(relativePath) && relativePath !== '..' && !relativePath.startsWith(`..${sep}`); + } catch (error) { + if (isParamsObject(error) && error.code === 'ENOENT') { + return false; + } + throw error; + } + }) === true; + } + + private _projectRelayRootSnapshot(client: IConnectedClient, snapshot: IStateSnapshot): IStateSnapshot { + if (client.transport.relayClientId === undefined || !isAhpRootChannel(snapshot.resource) || !hasKey(snapshot.state, { agents: true })) { + return snapshot; + } + if (client.transport.relayAuthenticated !== false) { + // Host-wide configuration may contain credentials; it is not a relay-owned surface. + const { config: _config, ...state } = snapshot.state; + return { ...snapshot, state }; + } + return { + ...snapshot, + state: { + agents: snapshot.state.agents.map(agent => ({ + provider: agent.provider, + displayName: agent.displayName, + description: agent.description, + models: agent.models, + protectedResources: agent.protectedResources, + capabilities: agent.capabilities, + })), + _meta: this._config.relayRootMeta, + }, + }; + } + + private _getSnapshot(channel: string): IStateSnapshot | undefined { + const snapshot = this._stateManager.getSnapshot(channel); + if (snapshot && this._config.relayRootMeta && isAhpRootChannel(channel) && hasKey(snapshot.state, { agents: true })) { + return { ...snapshot, state: { ...snapshot.state, _meta: { ...snapshot.state._meta, ...this._config.relayRootMeta } } }; + } + return snapshot; + } + + private _requireRelayMutation(client: IConnectedClient): void { + if (this._config.relayRoots && client.transport.relayPassive !== false) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidRequest, 'Passive relay connections cannot mutate sessions'); + } + } + + private _isGrantedRelayDirectory(directory: string): boolean { + if (!this._config.relayRoots) { + return true; + } + try { + const uri = URI.parse(directory, true); + if (uri.scheme !== 'file') { + return false; + } + const path = realpathSync(uri.fsPath); + return this._config.relayRoots.some(root => { + const pathFromRoot = relative(root, path); + return !pathFromRoot.startsWith('..') && !isAbsolute(pathFromRoot); + }); + } catch { + return false; + } + } + async whenIdle(): Promise { while (this._inflightRequests.size > 0) { await Promise.all([...this._inflightRequests].map(promise => promise.then(() => { }, () => { }))); @@ -2008,7 +2417,7 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien } catch { throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'session must be a valid URI string'); } - if (!AgentSession.provider(session) || !session.path.startsWith('/') || session.path.length < 2 + if (!session.scheme || !session.path.startsWith('/') || session.path.length < 2 || session.authority || session.query || session.fragment || parseChatUri(session)) { throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'session must be an Agent Session URI'); } @@ -2087,7 +2496,7 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien } catch { return Promise.reject(new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'session must be a valid URI string')); } - if (!AgentSession.provider(session)) { + if (!session.scheme) { return Promise.reject(new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'session must be an Agent Session URI')); } const chatParam = params['chat']; @@ -2129,7 +2538,7 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien } catch { return Promise.reject(new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'session must be a valid URI string')); } - if (!AgentSession.provider(session)) { + if (!session.scheme) { return Promise.reject(new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'session must be a valid Agent Session URI')); } return this._agentService.createDetachedWorktree(session, prompt).then(result => ({ @@ -2213,7 +2622,7 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien } catch { return Promise.reject(new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'session must be a valid URI string')); } - if (!AgentSession.provider(session)) { + if (!session.scheme) { return Promise.reject(new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'session must be an Agent Session URI')); } } @@ -2309,6 +2718,9 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien } private _broadcastNotification(notification: INotification): void { + if (this._config.relayRoots) { + return; + } // Each protocol notification now ships as its own top-level method. The // `type` discriminant on our local {@link ProtocolNotification} union is // the wire-level method name, so we can route it directly. @@ -2316,10 +2728,23 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien // eslint-disable-next-line local/code-no-dangerous-type-assertions const msg = { jsonrpc: '2.0', method: type, params } as AhpServerNotification; for (const record of this._clients.values()) { - this._getActiveClientFromRecord(record)?.transport.send(msg); + const client = this._getActiveClientFromRecord(record); + const session = notification.type === 'root/sessionAdded' ? notification.summary.resource + : notification.type === 'root/sessionRemoved' || notification.type === 'root/sessionSummaryChanged' ? notification.session : undefined; + if (client && client.transport.relayAuthenticated !== false && (session === undefined || this._isSessionVisible(client, session))) { + client.transport.send(msg); + } } } + private _isSessionVisible(client: IConnectedClient, session: string): boolean { + return !client.legacySessionUris || URI.parse(session).scheme !== 'ahp-session'; + } + + private _isChannelVisible(client: IConnectedClient, channel: string): boolean { + return this._isSessionVisible(client, parseChatUri(channel)?.session ?? channel); + } + /** * Forward an MCP server-originated notification (e.g. * `notifications/tools/list_changed`) over the AHP transport. The @@ -2330,6 +2755,9 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien * client already knows which channels it cares about. */ private _broadcastMcpNotification(notification: IMcpNotification): void { + if (this._config.relayRoots) { + return; + } const params: Record = { ...(notification.params ?? {}), channel: notification.channel }; // MCP notifications don't share a discriminated `method` literal // with the known {@link AhpServerNotification} union, so cast @@ -2337,7 +2765,10 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien // eslint-disable-next-line local/code-no-dangerous-type-assertions const msg = { jsonrpc: '2.0' as const, method: notification.method, params } as unknown as AhpServerNotification; for (const record of this._clients.values()) { - this._getActiveClientFromRecord(record)?.transport.send(msg); + const client = this._getActiveClientFromRecord(record); + if (client && client.transport.relayAuthenticated !== false) { + client.transport.send(msg); + } } } @@ -2416,6 +2847,13 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien } private _isRelevantToClient(client: IConnectedClient, envelope: ActionEnvelope): boolean { + if (client.transport.relayAuthenticated === false || !this._isChannelVisible(client, envelope.channel)) { + return false; + } + if (client.transport.relayClientId !== undefined && envelope.action.type === ActionType.RootConfigChanged + && (!envelope.rejectionReason || envelope.origin?.clientId !== client.clientId)) { + return false; + } const sub = client.subscriptions.get(envelope.channel); if ((sub?.kind === ChannelKind.State || sub?.kind === ChannelKind.ResourceWatch) && sub.active) { return true; diff --git a/src/vs/platform/agentHost/node/shared/agentEditAttributionService.ts b/src/vs/platform/agentHost/node/shared/agentEditAttributionService.ts index 0a7842bf4f7d14..c4390108f97634 100644 --- a/src/vs/platform/agentHost/node/shared/agentEditAttributionService.ts +++ b/src/vs/platform/agentHost/node/shared/agentEditAttributionService.ts @@ -303,7 +303,7 @@ export class AgentEditAttributionService extends Disposable implements IAgentEdi this._excludeOtherSessionAgentIntervals(resource); } - const provider = getSessionProvider(edit.sessionUri); + const provider = edit.provider ?? getSessionProvider(edit.sessionUri); const modelSegment = edit.modelId ? `-$modelId:${edit.modelId}` : ''; const sourceKey = `source:Chat.applyEdits${modelSegment}-$harness:${provider}-$origin:agentHost`; const conversationId = AgentSession.id(edit.sessionUri); @@ -768,7 +768,7 @@ export class AgentEditAttributionService extends Disposable implements IAgentEdi trackingScope, coverageGap: resource.coverageGap, coverageGapCutoffCeiling: undefined, - githubTelemetryEnabled: getSessionProvider(resource.sessionUri) === 'copilotcli', + githubTelemetryEnabled: getSessionProvider(resource.sessionUri) === 'copilotcli' || [...resource.sources.values()].some(source => source.harness === 'copilotcli'), lastSequence: resource.lastSequence, resources: [resource], standaloneAcknowledgements: [], diff --git a/src/vs/platform/agentHost/node/shared/agentServerToolHost.ts b/src/vs/platform/agentHost/node/shared/agentServerToolHost.ts index 515feb953e0fc5..9853c36d5f7451 100644 --- a/src/vs/platform/agentHost/node/shared/agentServerToolHost.ts +++ b/src/vs/platform/agentHost/node/shared/agentServerToolHost.ts @@ -5,7 +5,7 @@ import type { IAgentServerToolDefinition, IAgentServerToolHost } from '../../common/agentServerTools.js'; import { ActionType } from '../../common/state/protocol/common/actions.js'; -import { parseRequiredSessionUriFromChatUri, type StringOrMarkdown, type ToolDefinition, type URI } from '../../common/state/sessionState.js'; +import { isAhpChatChannel, isSubagentSession, parseRequiredSessionUriFromChatUri, type StringOrMarkdown, type ToolDefinition, type URI } from '../../common/state/sessionState.js'; import { createDecorator } from '../../../instantiation/common/instantiation.js'; import type { AgentHostStateManager } from '../agentHostStateManager.js'; @@ -192,7 +192,7 @@ export class AgentServerToolHost implements IAgentHostServerToolService { getDefinitionsForSession(sessionUri: URI): readonly IAgentServerToolDefinition[] { const materializedDefinitions = this._stateManager.getSessionState(sessionUri)?.serverTools; const isEphemeral = this._stateManager.isEphemeralSession(sessionUri); - return this._groups.flatMap(group => { + return this._groups.flatMap(group => { const currentDefinitions = group.getDefinitions?.() ?? group.definitions; if (materializedDefinitions && group.materializeDefinitions) { // A session's tool membership is fixed at materialization time, but @@ -211,7 +211,7 @@ export class AgentServerToolHost implements IAgentHostServerToolService { .filter(definition => group.isEnabled(definition.name, sessionUri) && group.isEnabledForSession(definition.name, sessionUri)) .map(definition => group.getDefinitionForSession?.(definition, sessionUri) ?? definition); return isEphemeral ? definitions.filter(definition => definition.enabledForEphemeralSessions) : definitions; - }); + }).filter(definition => !definition.topLevelChatOnly || !isSubagentSession(sessionUri)); } get toolNames(): readonly string[] { @@ -225,7 +225,8 @@ export class AgentServerToolHost implements IAgentHostServerToolService { ]; } - advertise(sessionUri: URI): void { + advertise(sessionOrChatUri: URI): void { + const sessionUri = isAhpChatChannel(sessionOrChatUri) ? parseRequiredSessionUriFromChatUri(sessionOrChatUri) : sessionOrChatUri; // Provider materialization can precede restore; AgentService advertises again once the session is registered. if (!this._stateManager.getSessionState(sessionUri)) { return; @@ -274,11 +275,14 @@ export class AgentServerToolHost implements IAgentHostServerToolService { } private _toProtocolDefinitions(definitions: readonly IAgentServerToolDefinition[]): ToolDefinition[] { - return definitions.map(({ enabledForEphemeralSessions: _enabledForEphemeralSessions, deferLoading: _deferLoading, ...definition }) => definition); + return definitions.map(({ enabledForEphemeralSessions: _enabledForEphemeralSessions, topLevelChatOnly: _topLevelChatOnly, deferLoading: _deferLoading, ...definition }) => definition); } private _isEnabledForSession(group: IServerToolGroup, chatUri: URI, toolName: string, requestedToolName = toolName): boolean { const sessionUri = parseRequiredSessionUriFromChatUri(chatUri); + if (group.definitions.find(definition => definition.name === toolName)?.topLevelChatOnly && isSubagentSession(sessionUri)) { + return false; + } if (!group.isEnabledForSession(toolName, sessionUri)) { return false; } diff --git a/src/vs/platform/agentHost/node/shared/editArcReporter.ts b/src/vs/platform/agentHost/node/shared/editArcReporter.ts index 05488290c9c76e..ab6e6b3b49e7b7 100644 --- a/src/vs/platform/agentHost/node/shared/editArcReporter.ts +++ b/src/vs/platform/agentHost/node/shared/editArcReporter.ts @@ -30,6 +30,7 @@ type IAgentHostEditArcTelemetryEvent = IEditArcTelemetryEvent & IAgentHostEventT type IAgentHostEditArcTelemetryClassification = IEditArcTelemetryClassification & IAgentHostEventClassification; export interface IEditArcReporterLaunchParams { + readonly provider?: string; readonly clientContext?: IAgentHostClientTelemetryContext; readonly sessionUri: string; readonly turnId: string; @@ -317,7 +318,7 @@ class EditArcReporter extends Disposable { async emit(timeDelayMs: number): Promise { const sessionUri = isAhpChatChannel(this._params.sessionUri) ? parseRequiredSessionUriFromChatUri(this._params.sessionUri) : this._params.sessionUri; - const provider = AgentSession.provider(sessionUri) ?? 'unknown'; + const provider = this._params.provider ?? AgentSession.provider(sessionUri) ?? 'unknown'; const originalLineCounts = new EditArcTracker(this._params.beforeText, this._params.initialEdit).getLineCountInfo(); const currentLineCounts = this._tracker.getLineCountInfo(); const event: IAgentHostEditArcTelemetryEvent = { diff --git a/src/vs/platform/agentHost/node/shared/editSurvivalReporter.ts b/src/vs/platform/agentHost/node/shared/editSurvivalReporter.ts index 61c111b738ad69..4a9ade7c878ba6 100644 --- a/src/vs/platform/agentHost/node/shared/editSurvivalReporter.ts +++ b/src/vs/platform/agentHost/node/shared/editSurvivalReporter.ts @@ -30,6 +30,7 @@ import { computeChunkedEditSurvival, computeWholeFileEditSurvival } from './edit * revisit when we have a notebook-aware tracker. */ export interface IEditSurvivalReporterLaunchParams { + readonly provider?: string; readonly clientContext?: IAgentHostClientTelemetryContext; /** Full session URI string (e.g. `claude:/abc123`). */ readonly sessionUri: string; @@ -204,7 +205,7 @@ class SessionEditSurvivalReporter extends Disposable { 'agentHost.trackEditSurvival', { ...toInitiatorTelemetry(this._params.clientContext), - provider: AgentSession.provider(sessionUri) ?? 'unknown', + provider: this._params.provider ?? AgentSession.provider(sessionUri) ?? 'unknown', modelId: this._params.modelId ?? '', toolName: this._params.toolName ?? '', agentSessionId: AgentSession.id(sessionUri), diff --git a/src/vs/platform/agentHost/node/shared/fileEditTracker.ts b/src/vs/platform/agentHost/node/shared/fileEditTracker.ts index 60e039baf35d8a..2ac4d4f3d04e0a 100644 --- a/src/vs/platform/agentHost/node/shared/fileEditTracker.ts +++ b/src/vs/platform/agentHost/node/shared/fileEditTracker.ts @@ -42,6 +42,7 @@ export class FileEditTracker { constructor( private readonly _sessionUri: string, private readonly _db: ISessionDatabase, + private readonly _provider: string | undefined, @IFileService private readonly _fileService: IFileService, @ILogService private readonly _logService: ILogService, @IDiffComputeService private readonly _diffComputeService: IDiffComputeService, @@ -158,6 +159,7 @@ export class FileEditTracker { this._editSurvivalReporterFactory.launch({ clientContext, sessionUri: this._sessionUri, + ...(this._provider ? { provider: this._provider } : {}), turnId, toolCallId, filePath, @@ -185,6 +187,7 @@ export class FileEditTracker { try { marker = await this._editAttributionService.recordEdit({ sessionUri: this._sessionUri, + ...(this._provider ? { provider: this._provider } : {}), chatUri, turnId, toolCallId, @@ -204,6 +207,7 @@ export class FileEditTracker { this._editArcReporterService.reportEdit({ clientContext, sessionUri: this._sessionUri, + ...(this._provider ? { provider: this._provider } : {}), turnId, toolCallId, filePath, diff --git a/src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts b/src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts index ef1c1cc6f3640f..1171f51d30df2e 100644 --- a/src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts +++ b/src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts @@ -10,7 +10,7 @@ import { URI } from '../../../../base/common/uri.js'; import { AgentSession } from '../../common/agent.js'; import { ActionType } from '../../common/state/protocol/common/actions.js'; import { isCustomizationEnabled } from '../../common/customizationEnablement.js'; -import { McpServerSource, readMcpServerDisplayName, readMcpServerSource, withMcpServerDisplayNameMeta, withMcpServerSourceMeta } from '../../common/meta/mcpCustomizationMeta.js'; +import { McpServerSource, readMcpServerDisplayName, readMcpServerSource, readMcpServerSourcePlugin, withMcpServerControllingSettingMeta, withMcpServerDisplayNameMeta, withMcpServerSourceMeta, withMcpServerSourcePluginMeta } from '../../common/meta/mcpCustomizationMeta.js'; import { CustomizationLoadStatus, CustomizationType, McpServerStatus, type AhpMcpUiHostCapabilities, type Customization, type CustomizationEnablement, type McpServerCustomization, type McpServerState } from '../../common/state/protocol/channels-session/state.js'; import { DEFAULT_MCP_APP, DEFAULT_MCP_APP_CAPABILITIES } from '../../common/state/protocol/mcpAppDefaults.js'; import { parseChatUri } from '../../common/state/sessionState.js'; @@ -40,11 +40,24 @@ export interface ISdkMcpServer { readonly allowAuthRequiredToStarting?: boolean; /** Explicit runtime enablement when the SDK distinguishes disabled from stopped. */ readonly enabled?: boolean; - /** Plugin that contributed this MCP server customization. */ - readonly pluginName?: string; + /** Plugin that supplied this server's configuration. Omitted on lifecycle updates; null clears a previously known plugin. */ + readonly pluginName?: string | null; readonly pluginVersion?: string; } +/** Where a top-level server comes from, as last reported by its provider. */ +export interface ISdkMcpServerProvenance { + readonly source: McpServerSource | undefined; + readonly sourcePlugin: string | undefined; +} + +function readTopLevelProvenance(customization: McpServerCustomization | undefined): ISdkMcpServerProvenance { + return { + source: readMcpServerSource(customization), + sourcePlugin: readMcpServerSourcePlugin(customization), + }; +} + /** * Runtime fields of an MCP server customization that this controller * owns — the high-frequency `state`/`channel` pair. Consumers overlay @@ -116,6 +129,7 @@ export { DEFAULT_MCP_APP_CAPABILITIES, DEFAULT_MCP_APP }; * Options for {@link McpCustomizationController}. */ export interface IMcpCustomizationControllerOptions { + readonly provider?: string; /** Concrete chat URI used for MCP App routing. */ readonly chatUri: URI; /** Emits a {@link SessionAction} into the session's action stream. */ @@ -124,6 +138,11 @@ export interface IMcpCustomizationControllerOptions { readonly pluginMcpServerSources?: () => ReadonlyMap | undefined; /** Resolves the scoped enablement to publish for a temporarily top-level server. */ readonly resolveEnablement?: (server: McpServerCustomization, owningPluginUri: string | undefined) => readonly CustomizationEnablement[] | undefined; + /** + * Returns the VS Code setting that controls whether this host includes a server it adds itself, given the + * server's current provenance. Published so clients can offer the setting without guessing from the name. + */ + readonly controllingSetting?: (serverName: string, provenance: ISdkMcpServerProvenance) => string | undefined; /** * MCP App capabilities to advertise on every ready server. Defaults * to {@link DEFAULT_MCP_APP_CAPABILITIES}. @@ -148,8 +167,7 @@ export function buildMcpTopLevelCustomizationId(providerId: string, sessionId: s return `mcp-top-level:${providerId}:${sessionId}:${serverName}`; } -export function buildMcpChannel(chatUri: URI, serverName: string): string { - const providerId = getMcpChannelProviderId(chatUri); +export function buildMcpChannel(chatUri: URI, serverName: string, providerId = getMcpChannelProviderId(chatUri)): string { return `mcp://${providerId}/${encodeURIComponent(chatUri.toString())}/${encodeURIComponent(serverName)}`; } @@ -205,7 +223,7 @@ export class McpCustomizationController extends Disposable { throw new Error(`Malformed AHP chat URI: ${this._chatUri.toString()}`); } this._sessionUri = URI.parse(chat.session); - this._providerId = AgentSession.provider(this._sessionUri) ?? ''; + this._providerId = this._options.provider ?? this._stateManager.getSessionSummary(chat.session)?.provider ?? AgentSession.provider(this._sessionUri) ?? ''; this._sessionId = AgentSession.id(this._sessionUri); if (!this._providerId || !this._sessionId) { throw new Error(`Malformed Agent Host session URI: ${chat.session}`); @@ -226,7 +244,7 @@ export class McpCustomizationController extends Disposable { if (entry.topLevelId === undefined) { continue; } - out.push(this._buildTopLevel(entry.topLevelId, entry.serverName, entry.displayName, entry.state, entry.enabled, readMcpServerSource(entry.topLevelCustomization), entry.topLevelCustomization?.uri)); + out.push(this._buildTopLevel(entry.topLevelId, entry.serverName, entry.displayName, entry.state, entry.enabled, readTopLevelProvenance(entry.topLevelCustomization), entry.topLevelCustomization?.uri)); } return out; } @@ -392,9 +410,14 @@ export class McpCustomizationController extends Disposable { } topLevelId = published?.topLevelId ?? this._mintTopLevelId(server.name); } - const source = server.source ?? readMcpServerSource(previous?.topLevelCustomization); + // Lifecycle updates carry no provenance, so keep what was last reported or restored. + const known = readTopLevelProvenance(previous?.topLevelCustomization ?? this._findPublishedTopLevel(topLevelId)); + const provenance: ISdkMcpServerProvenance = { + source: server.source ?? known.source, + sourcePlugin: server.pluginName !== undefined ? server.pluginName ?? undefined : known.sourcePlugin, + }; const sourceUri = server.sourceUri !== undefined ? server.sourceUri : previous?.topLevelCustomization?.uri; - const customization = this._buildTopLevel(topLevelId, server.name, displayName, state, enabled, source, sourceUri); + const customization = this._buildTopLevel(topLevelId, server.name, displayName, state, enabled, provenance, sourceUri); const resolvedDisplayName = readMcpServerDisplayName(customization); const customizationChanged = force || previous?.topLevelId !== topLevelId || !equals(previous?.topLevelCustomization, customization); if (customizationChanged || previous?.enabled !== enabled) { @@ -509,14 +532,19 @@ export class McpCustomizationController extends Disposable { && customization.load?.kind === CustomizationLoadStatus.Loading); } + private _findPublishedTopLevel(id: string): McpServerCustomization | undefined { + return getMcpServerCustomizations(this._stateManager.getSessionState(this._sessionUri.toString())?.customizations ?? []) + .find(customization => customization.id === id); + } + private _buildChannel(serverName: string, state: McpServerState): string | undefined { if (state.kind !== McpServerStatus.Ready) { return undefined; } - return buildMcpChannel(this._chatUri, serverName); + return buildMcpChannel(this._chatUri, serverName, this._providerId); } - private _buildTopLevel(id: string, serverName: string, displayName: string | undefined, state: McpServerState, enabled: boolean, source?: McpServerSource, sourceUri?: string | null): McpServerCustomization { + private _buildTopLevel(id: string, serverName: string, displayName: string | undefined, state: McpServerState, enabled: boolean, provenance: ISdkMcpServerProvenance, sourceUri?: string | null): McpServerCustomization { const channel = this._buildChannel(serverName, state); const owningPluginUri = this.pluginMcpServerSources?.get(serverName); // Per AHP spec, `mcpApp` is a static capability declaration — @@ -527,10 +555,15 @@ export class McpCustomizationController extends Disposable { const mcpApp = this._options.capabilities ? { capabilities: this._options.capabilities } : DEFAULT_MCP_APP; - const existing = getMcpServerCustomizations(this._stateManager.getSessionState(this._sessionUri.toString())?.customizations ?? []) - .find(customization => customization.id === id); + const existing = this._findPublishedTopLevel(id); // `SessionCustomizationUpdated` replaces the whole customization, so keep opaque entries owned by others. - const meta = withMcpServerDisplayNameMeta(withMcpServerSourceMeta(existing?._meta, source), displayName); + const meta = withMcpServerControllingSettingMeta( + withMcpServerSourcePluginMeta( + withMcpServerDisplayNameMeta(withMcpServerSourceMeta(existing?._meta, provenance.source), displayName), + provenance.sourcePlugin, + ), + this._options.controllingSetting?.(serverName, provenance), + ); const uri = (sourceUri === undefined ? existing?.uri : sourceUri) ?? this._mintTopLevelId(serverName); const customization: McpServerCustomization = { type: CustomizationType.McpServer, @@ -690,6 +723,10 @@ export function parseMcpChannelUri(uri: string): IMcpChannelRoute | undefined { if (!providerId || !serverName) { return undefined; } + const parsedChat = parseChatUri(chatUri); + if (parsedChat && URI.parse(parsedChat.session).scheme === 'ahp-session') { + return { providerId, chatUri, serverName }; + } let routedProviderId: string; try { routedProviderId = getMcpChannelProviderId(chatUri); diff --git a/src/vs/platform/agentHost/node/shared/persistSessionMetadata.ts b/src/vs/platform/agentHost/node/shared/persistSessionMetadata.ts index 0bb37e2197417a..9c288d9393f28a 100644 --- a/src/vs/platform/agentHost/node/shared/persistSessionMetadata.ts +++ b/src/vs/platform/agentHost/node/shared/persistSessionMetadata.ts @@ -10,6 +10,7 @@ import type { ISessionDataService } from '../../common/sessionDataService.js'; export const SESSION_CUSTOM_TITLE_KEY = 'customTitle'; export const SESSION_CUSTOM_TITLE_SOURCE_KEY = 'customTitleSource'; export const SESSION_ARTIFACTS_KEY = 'sessionArtifacts'; +export const SESSION_WORKING_DIRECTORIES_KEY = 'agentHost.sessionWorkingDirectories'; export const AGENT_HOST_TITLE_SOURCE_USER = 'user'; export const AGENT_HOST_TITLE_SOURCE_AGENT = 'agent'; export const AGENT_HOST_TITLE_SOURCE_AUTO = 'auto'; @@ -19,6 +20,18 @@ export type AgentHostTitleSource = | typeof AGENT_HOST_TITLE_SOURCE_AGENT | typeof AGENT_HOST_TITLE_SOURCE_AUTO; +/** Reads host-owned aggregate roots independently of a provider's default-chat cwd. */ +export function parseSessionWorkingDirectories(value: string | undefined): readonly string[] | undefined { + if (value === undefined) { + return undefined; + } + const directories: unknown = JSON.parse(value); + if (!Array.isArray(directories) || !directories.every(directory => typeof directory === 'string' && URI.parse(directory, true).scheme.length > 0)) { + throw new Error('Invalid persisted session working directories'); + } + return directories; +} + export function customChatTitleMetadataKey(chat: string): string { return `customChatTitle:${chat}`; } diff --git a/src/vs/platform/agentHost/node/shared/serverToolGroups.ts b/src/vs/platform/agentHost/node/shared/serverToolGroups.ts index 1d6bd5d95835f2..232888ecb6ce32 100644 --- a/src/vs/platform/agentHost/node/shared/serverToolGroups.ts +++ b/src/vs/platform/agentHost/node/shared/serverToolGroups.ts @@ -8,6 +8,7 @@ import { createSessionServerToolGroup, type ISessionServerToolAccessor } from '. import type { IServerToolDisplay, IServerToolDisplayResult, IServerToolGroup } from './agentServerToolHost.js'; import { createAgentMergeServerToolGroup, type IAgentMergeToolAccessor } from './agentMergeServerTools.js'; import { createArtifactServerToolGroup, type IArtifactServerToolAccessor } from './artifactServerTools.js'; +import { createSessionIsolationToolGroup, type ISessionIsolationToolAccessor } from './sessionIsolationTools.js'; /** * Builds the server-tool groups contributed to every agent host session, in @@ -25,12 +26,13 @@ import { createArtifactServerToolGroup, type IArtifactServerToolAccessor } from * When omitted (the pure display path) the session group's `execute` is inert, * but its definitions and display remain available. */ -export function buildServerToolGroups(sessionAccessor?: ISessionServerToolAccessor, agentMergeAccessor?: IAgentMergeToolAccessor, artifactAccessor?: IArtifactServerToolAccessor, areAgentOrchestrationLimitsEnabled?: () => boolean): readonly IServerToolGroup[] { +export function buildServerToolGroups(sessionAccessor?: ISessionServerToolAccessor, agentMergeAccessor?: IAgentMergeToolAccessor, artifactAccessor?: IArtifactServerToolAccessor, areAgentOrchestrationLimitsEnabled?: () => boolean, isolationAccessor?: ISessionIsolationToolAccessor): readonly IServerToolGroup[] { return [ feedbackServerToolGroup, createSessionServerToolGroup(sessionAccessor, areAgentOrchestrationLimitsEnabled), createAgentMergeServerToolGroup(agentMergeAccessor), createArtifactServerToolGroup(artifactAccessor), + createSessionIsolationToolGroup(isolationAccessor), ]; } diff --git a/src/vs/platform/agentHost/node/shared/sessionIsolationTools.ts b/src/vs/platform/agentHost/node/shared/sessionIsolationTools.ts new file mode 100644 index 00000000000000..70ad6e905171fe --- /dev/null +++ b/src/vs/platform/agentHost/node/shared/sessionIsolationTools.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 { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import { SessionServerToolName } from '../../common/serverToolNames.js'; +import type { IServerToolGroup } from './agentServerToolHost.js'; + +export interface ISessionIsolationToolAccessor { + /** Stable provider support; exact-chat eligibility is checked when requesting isolation. */ + supportsChatIsolation(session: URI): boolean; + requestChatIsolation(chat: URI, turnId: string): void; +} + +export function createSessionIsolationToolGroup(accessor?: ISessionIsolationToolAccessor): IServerToolGroup { + return { + definitions: [{ + name: SessionServerToolName.IsolateSession, + description: 'Move only the current chat to a new Git worktree when the user wants to continue this task without changing its current checkout. This preserves the chat and its history; it does not create a separate task or move other chats, even those sharing the same folder. The session workspace will include the new worktree. The original folder is unchanged, and uncommitted edits are not copied except configured worktree include-files. Requires a chat working in one local Git folder. Follow the tool confirmation flow. Make this the final tool call and end the turn after it succeeds; the host then moves this chat and continues its task automatically. Do not poll or repeat the request.', + inputSchema: { type: 'object', properties: {} }, + topLevelChatOnly: true, + }], + isEnabled: () => true, + isEnabledForSession: (_tool, session) => accessor?.supportsChatIsolation(URI.parse(session)) === true, + canRequireConfirmation: () => true, + getDisplay: () => ({ + displayName: localize('isolateSession.name', "Change Workspace to a New Worktree"), + invocationMessage: localize('isolateSession.invocation', "Requesting a workspace change to a new worktree"), + pastTenseMessage: localize('isolateSession.complete', "Requested a workspace change to a new worktree"), + confirmationTitle: localize('isolateSession.confirmTitle', "Change Workspace?"), + confirmationMessage: localize('isolateSession.confirm', "Change only this chat's workspace to a new worktree? Other chats and the original folder are left unchanged. Uncommitted edits are not copied, except configured worktree include-files."), + hideConfirmationInput: true, + }), + execute: (_stateManager, context, _tool, args) => { + if (!accessor || !context.turnId) { + throw new Error(localize('isolateSession.activeTurnRequired', "Changing a chat's workspace with this tool requires an active turn.")); + } + if (!args || typeof args !== 'object' || Array.isArray(args) || Object.keys(args).length > 0) { + throw new Error('isolate_session takes no arguments.'); + } + accessor.requestChatIsolation(URI.parse(context.chatUri), context.turnId); + return 'Moving this chat to a new worktree is scheduled. End this turn now without calling more tools or replying. The host will move only this chat to a new worktree, update the session workspace, and continue the original task automatically. Other chats remain unchanged.'; + }, + }; +} diff --git a/src/vs/platform/agentHost/node/shared/sessionServerTools.ts b/src/vs/platform/agentHost/node/shared/sessionServerTools.ts index ed09ff791af56e..7920dff9adb04a 100644 --- a/src/vs/platform/agentHost/node/shared/sessionServerTools.ts +++ b/src/vs/platform/agentHost/node/shared/sessionServerTools.ts @@ -121,7 +121,7 @@ const setWorkspaceInputSchema: ToolDefinition['inputSchema'] = { properties: { workspaceFolder: { type: 'string', - description: 'Absolute local folder path or file URI to set as the current session\'s workspace. Use an exact path from the user or `list_sessions`; do not guess.', + description: 'Absolute local folder path or file URI to set as the current chat\'s workspace. Use an exact path from the user or `list_sessions`; do not guess.', }, isolation: { type: 'boolean', @@ -203,7 +203,7 @@ export const sessionServerToolDefinitions: IAgentServerToolDefinition[] = [ { name: SessionServerToolName.SetWorkspace, title: 'Set Workspace', - description: 'Attach a real workspace only to modify its files or run commands requiring its project environment. Do not use for self-contained scratch work on attachments, pasted/generated content, or throwaway/exportable artifacts. The session, chat, and history are preserved. Immediately before every call, use the available user-input tool to ask one question confirming both workspace and isolation, even if already specified; tool approval is not confirmation. Set `isolation` to true for a managed Git worktree or false for the folder directly. After this turn, the host attaches the workspace and continues the original task. Make this the turn\'s final tool call.', + description: 'Set the current chat\'s workspace while preserving its identity and history. If a workspace is already attached, use this tool only when the user explicitly asks to change this chat\'s workspace. Do not infer a workspace change from a file path, a request to inspect another repository, or ordinary work in the current workspace. If `isolation` is false and the requested folder is already this chat\'s effective working directory, skip this tool and the confirmation question; tell the user the chat already uses that workspace and continue normally. Compare against this chat\'s working directory, not its session\'s project root. Creating a new worktree from the same folder is a real change, not a no-op. For a workspace-less quick chat, attach a workspace only to modify its files or run commands requiring its project environment; not for self-contained scratch work on attachments, pasted/generated content, or throwaway/exportable artifacts. Other chats keep their workspaces. Set `isolation` to true for a new managed Git worktree or false to use the selected folder directly; files are not moved or copied to the selected folder. Immediately before every call, use the available user-input tool to ask one question confirming both workspace and isolation, even if already specified; tool approval is not confirmation. Make this the turn\'s final tool call and end the turn; the host changes the workspace and continues the original task automatically. Do not repeat the request.', inputSchema: setWorkspaceInputSchema, annotations: { readOnlyHint: false }, deferLoading: true, @@ -338,7 +338,8 @@ export interface IAgentServiceSessionServerToolAccessor { /** Complete dependency surface needed by the session server-tool group. */ export interface ISessionServerToolAccessor extends IAgentServiceSessionServerToolAccessor { - readonly requestSessionWorkspaceUpdate: (chat: URI, turnId: string, workspaceFolder: URI, isolation: boolean) => void; + /** Returns whether a workspace change was scheduled. */ + readonly requestSessionWorkspaceUpdate: (chat: URI, turnId: string, workspaceFolder: URI, isolation: boolean) => boolean; } export interface IRenameTitleResult { @@ -900,8 +901,8 @@ export async function applyCreateSessionTool(accessor: ISessionServerToolAccesso const currentSession = source ? currentSessionUri(source.toString()) : undefined; const supportsChatWorkingDirectories = currentSession === undefined || accessor.supportsChatWorkingDirectories(currentSession); const sessions = await getCreateSessionCatalog(accessor, rawArgs, supportsChatWorkingDirectories); - const currentProvider = currentSession ? AgentSession.provider(currentSession) : undefined; const defaults = source ? accessor.getCreationDefaults(source) : undefined; + const currentProvider = defaults?.provider ?? (currentSession ? (await accessor.getSession(currentSession))?.provider ?? AgentSession.provider(currentSession) : undefined); const args = getCreateSessionArgs(rawArgs, sessions, accessor.getModels(), currentProvider, supportsChatWorkingDirectories); if (args.relationship === 'currentSession') { if (!currentSession) { @@ -1070,14 +1071,14 @@ export function getCreateChatArgs(rawArgs: unknown, sessions: readonly IAgentSes } else { throw new Error(`Invalid ${SessionServerToolName.CreateChat} input: no session provided and the current session could not be determined.`); } - const model = resolveModel(modelName, models, AgentSession.provider(session)); + const model = resolveModel(modelName, models, sessions.find(candidate => isEqual(candidate.session, session))?.provider ?? AgentSession.provider(session)); return { session, prompt, ...(title !== undefined ? { title } : {}), ...(model !== undefined ? { model } : {}) }; } async function createChat(accessor: ISessionServerToolAccessor, args: IResolvedCreateChatArgs, source?: URI, sourceTurnId?: string, onChatAllocated?: (chat: URI) => Promise): Promise { const currentSession = source ? currentSessionUri(source.toString()) : undefined; const defaults = source ? accessor.getCreationDefaults(source) : undefined; - const targetProvider = AgentSession.provider(args.session); + const targetProvider = (await accessor.getSession(args.session))?.provider ?? AgentSession.provider(args.session); const model = args.model !== undefined ? { id: args.model.id } : targetProvider === defaults?.provider ? defaults?.model : undefined; const chatId = generateUuid(); const chat = URI.parse(buildChatUri(args.session.toString(), chatId)); @@ -1700,19 +1701,23 @@ export async function applyDeleteSessionTool(accessor: ISessionServerToolAccesso return `Deleted session ${session.toString()}. Reply with one short sentence confirming the session was deleted.`; } -/** Requests setting the workspace in place after the tool's active turn completes. */ +const workspaceUnchangedResultPrefix = 'This chat already uses'; + +/** Requests setting the workspace after the active turn, or reports that it is already selected. */ export function applySetWorkspaceTool(accessor: ISessionServerToolAccessor, rawArgs: unknown, chat: URI, turnId: string | undefined): string { if (!turnId) { throw new Error(`${SessionServerToolName.SetWorkspace} must run from an active chat turn.`); } const { workspaceFolder, isolation } = getSetWorkspaceArgs(rawArgs); - accessor.requestSessionWorkspaceUpdate(chat, turnId, workspaceFolder, isolation); + if (!accessor.requestSessionWorkspaceUpdate(chat, turnId, workspaceFolder, isolation)) { + return `${workspaceUnchangedResultPrefix} ${workspaceFolder.toString()}. No workspace change was scheduled. Continue the current turn normally; no automatic continuation will be started. Do not call set_workspace again for this unchanged workspace.`; + } return isolation ? `An isolated worktree will be created from ${workspaceFolder.toString()} and set as the workspace after this turn ends. End this turn now without calling more tools or replying; the host will continue the original task automatically in the isolated workspace.` : `Workspace will be set to ${workspaceFolder.toString()} after this turn ends. End this turn now without calling more tools or replying; the host will continue the original task automatically in the selected workspace.`; } -function getSessionToolDisplay(toolName: string, args: unknown, _result?: IServerToolDisplayResult): IServerToolDisplay | undefined { +function getSessionToolDisplay(toolName: string, args: unknown, result?: IServerToolDisplayResult): IServerToolDisplay | undefined { switch (toolName) { case SessionServerToolName.ListSessions: return { @@ -1794,15 +1799,17 @@ function getSessionToolDisplay(toolName: string, args: unknown, _result?: IServe ? basename(parseWorkspaceUri(input.workspaceFolder) ?? URI.file(input.workspaceFolder)) || workspaceFolder : workspaceFolder; const confirmationMessage = input?.isolation === true - ? localize('toolConfirm.setWorkspace.isolated', "Continue this session in {0} with changes isolated from the existing folder?", workspaceFolder) + ? localize('toolConfirm.setWorkspace.isolated', "Change this chat's workspace to a new worktree of {0}? Other chats keep their workspaces.", workspaceFolder) : input?.isolation === false - ? localize('toolConfirm.setWorkspace.direct', "Continue this session in {0} and make changes directly in that folder?", workspaceFolder) - : localize('toolConfirm.setWorkspace.generic', "Continue this session in {0}?", workspaceFolder); + ? localize('toolConfirm.setWorkspace.direct', "Change this chat's workspace to {0} and make changes directly in that folder? Other chats keep their workspaces.", workspaceFolder) + : localize('toolConfirm.setWorkspace.generic', "Change this chat's workspace to {0}?", workspaceFolder); return { - displayName: localize('toolName.setWorkspace', "Set Workspace"), - invocationMessage: localize('toolInvoke.setWorkspace', "Setting workspace"), - pastTenseMessage: localize('toolComplete.setWorkspace', "Scheduled workspace change"), - confirmationTitle: localize('toolConfirm.setWorkspace.title', "Continue in {0}?", workspaceName), + displayName: localize('toolName.setWorkspace', "Change Workspace"), + invocationMessage: localize('toolInvoke.setWorkspace', "Requesting a workspace change"), + pastTenseMessage: result?.success && result.text?.startsWith(workspaceUnchangedResultPrefix) + ? localize('toolComplete.setWorkspace.unchanged', "Workspace unchanged") + : localize('toolComplete.setWorkspace', "Scheduled workspace change"), + confirmationTitle: localize('toolConfirm.setWorkspace.title', "Change Workspace to {0}?", workspaceName), confirmationMessage, hideConfirmationInput: true, }; diff --git a/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts b/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts index 8ba876afd89443..0a2bda3145051a 100644 --- a/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts +++ b/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts @@ -26,6 +26,7 @@ import { DEV_CONTAINER_WORKTREE_DATA_ID_PREFIX, isAgentDevContainerWorktreeHandl import { getRepositoryRootFromWorktree, getWorktreesRoot } from '../../common/worktreePaths.js'; import { AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, ResponsePart, ResponsePartKind, Turn } from '../../common/state/sessionState.js'; import { AGENT_BRANCH_PREFIX, IAgentBranchNameGenerator } from './agentBranchNameGenerator.js'; +import { ADDITIONAL_WORKTREES_METADATA_KEY, readSessionAdditionalWorktrees } from './sessionAdditionalWorktrees.js'; export const IAgentHostWorktreeIsolation = createDecorator('agentHostWorktreeIsolation'); @@ -71,10 +72,12 @@ export interface IAgentHostWorktreeIsolation extends IAgentHostWorktreePendingSt persistCreationFailure(sessionUri: URI, sessionId: string, diagnostic: string | undefined): Promise; applyRestoreAnnouncement(sessionUri: URI, turns: readonly Turn[]): Promise; prepareSessionDeletion(sessionUri: URI, sessionId: string): Promise; + /** Retains the previous checkout under session ownership without using it as the current workspace. */ + retainSessionWorktree(sessionUri: URI, sessionId: string): Promise; canAutomaticallyDeleteArchivedSession(sessionUri: URI): Promise; isWorktreeCleanupNeeded(sessionUri: URI): Promise; removeSessionWorktree(sessionId: string, worktree: ISessionWorktree | undefined): Promise; - discardSessionWorktree(sessionUri: URI, sessionId: string, worktree: ISessionWorktree | undefined): Promise; + discardSessionWorktree(sessionUri: URI, sessionId: string, worktree: ISessionWorktree | undefined, options?: { readonly preserveWorkingDirectory: boolean }): Promise; cleanupWorktree(sessionUri: URI, sessionId: string): Promise; cleanupWorktreeOnArchive(sessionUri: URI, sessionId: string): Promise; recreateWorktreeOnUnarchive(sessionUri: URI, sessionId: string): Promise; @@ -108,6 +111,7 @@ const DETACHED_WORKTREE_CREATED_AT = 'vscode.devContainerWorktree.createdAt'; const DETACHED_WORKTREE_CLAIMED = 'vscode.devContainerWorktree.claimed'; const DETACHED_WORKTREE_LAST_SEEN_AT = 'vscode.devContainerWorktree.lastSeenAt'; const DETACHED_WORKTREE_DELETION_PENDING = 'vscode.devContainerWorktree.deletionPending'; +const DETACHED_WORKTREE_OWNER = 'vscode.devContainerWorktree.owner'; const DETACHED_WORKTREE_RECONCILE_GRACE_MS = 24 * 60 * 60 * 1000; // TODO@roblourens: Remove after ~November 2026, when pre-July 2026 sessions no longer need their worktree path/root reconstructed from this legacy key. const LEGACY_WORKTREE_META_WORKING_DIRECTORY = 'copilot.workingDirectory'; @@ -701,6 +705,7 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI let lastSeenAt = Number.NaN; let claimed = false; let deletionPending = false; + let owner: string | undefined; try { const metadata = await ref.object.getMetadataObject({ [DETACHED_WORKTREE_SCOPE]: true, @@ -708,15 +713,31 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI [DETACHED_WORKTREE_CLAIMED]: true, [DETACHED_WORKTREE_LAST_SEEN_AT]: true, [DETACHED_WORKTREE_DELETION_PENDING]: true, + [DETACHED_WORKTREE_OWNER]: true, }); recordScope = metadata[DETACHED_WORKTREE_SCOPE]; createdAt = Number(metadata[DETACHED_WORKTREE_CREATED_AT]); claimed = metadata[DETACHED_WORKTREE_CLAIMED] === 'true'; lastSeenAt = Number(metadata[DETACHED_WORKTREE_LAST_SEEN_AT]); deletionPending = metadata[DETACHED_WORKTREE_DELETION_PENDING] === 'true'; + owner = metadata[DETACHED_WORKTREE_OWNER]; } finally { ref.dispose(); } + if (owner && !deletionPending) { + let handled: boolean; + try { + handled = await this._reconcileOwnedDetachedWorktree(handle, record, owner, claimed); + } catch (error) { + // Ownership cannot be verified, so keep the record and its worktree untouched and keep reconciling the others. + this._logService.warn(`[${this._logLabel}:${handle}] Retaining detached worktree because owner '${owner}' could not be verified: ${errorMessage(error)}`); + return; + } + if (handled) { + return; + } + claimed = false; + } if (!unclaimedOnly && recordScope === scope && activeHandles.has(handle)) { const activeRef = this._sessionDataService.openDatabase(record); try { @@ -728,7 +749,7 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI } const referenceTime = claimed ? lastSeenAt : createdAt; const withinGracePeriod = Number.isFinite(referenceTime) && Date.now() - referenceTime < DETACHED_WORKTREE_RECONCILE_GRACE_MS; - if ((unclaimedOnly ? claimed && !deletionPending : recordScope !== scope) || (!deletionPending && withinGracePeriod)) { + if ((unclaimedOnly ? claimed && !deletionPending : !owner && recordScope !== scope) || (!deletionPending && withinGracePeriod)) { return; } const metadata = await this._readWorktreeMetadata(record); @@ -769,6 +790,42 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI } } + /** + * Resolves a detached worktree record that names an owning session. + * Returns `true` when the record was fully handled (claimed or discarded) and + * `false` when the owner no longer holds the worktree so normal cleanup applies. + * Throws when ownership cannot be verified. + */ + private async _reconcileOwnedDetachedWorktree(handle: string, record: URI, owner: string, claimed: boolean): Promise { + const ownerUri = URI.parse(owner, true); + // Cheap path first: a listed handle needs no git probing of the record or owner. + if ((await readSessionAdditionalWorktrees(this._sessionDataService, ownerUri)).some(worktree => worktree.handle === handle)) { + if (!claimed) { + await this.claimDetachedWorktree(handle); + } + return true; + } + const transferred = await this._readWorktreeMetadata(record); + // Read current ownership before re-listing so a concurrent retry cannot hide both sides of the atomic transfer. + const original = await this._readWorktreeMetadata(ownerUri); + const worktrees = await readSessionAdditionalWorktrees(this._sessionDataService, ownerUri); + if (worktrees.some(worktree => worktree.handle === handle)) { + if (!claimed) { + await this.claimDetachedWorktree(handle); + } + return true; + } + const retainedElsewhere = worktrees.some(worktree => isEqual(URI.parse(worktree.workingDirectory), transferred?.worktreePath) + && isEqual(URI.parse(worktree.repositoryRoot), transferred?.repositoryRoot)); + if (retainedElsewhere || (original?.worktreePath && isEqual(original.worktreePath, transferred?.worktreePath) + && isEqual(original.repositoryRoot, transferred?.repositoryRoot))) { + // An interrupted transfer still belongs to the original session; only discard the duplicate record. + await this._sessionDataService.deleteSessionData(record); + return true; + } + return false; + } + /** * Builds the `isolation` / `branch` schema contribution for * `resolveSessionConfig`. When {@link IResolveIsolationConfigRequest.workingDirectory} @@ -1072,7 +1129,8 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI // Repair or fall back below. } - const meta = await this._readWorktreeMetadata(sessionUri).catch(() => undefined); + const storedMetadata = await this._readWorktreeMetadata(sessionUri).catch(() => undefined); + const meta = isEqual(storedMetadata?.worktreePath, workingDirectory) ? storedMetadata : undefined; const archived = await this._isSessionArchived(sessionUri); if (archived) { if (meta?.repositoryRoot) { @@ -1152,6 +1210,89 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI return prependAnnouncementToFirstTurn(turns, buildWorktreeAnnouncementText(notice.branchName)); } + async retainSessionWorktree(sessionUri: URI, sessionId: string): Promise { + await this._sequencer.queue(sessionId, async () => { + const metadata = await this._readWorktreeMetadata(sessionUri); + const materialized = this._materializedWorktrees.get(sessionId); + if (materialized && (!isEqual(metadata?.worktreePath, materialized.worktree) || !metadata?.repositoryRoot)) { + throw new Error(`Cannot retain worktree '${materialized.worktree.fsPath}' without its persisted ownership metadata`); + } + const values: Record = { + [WORKTREE_META_BRANCH]: '', + [WORKTREE_META_PATH]: '', + [WORKTREE_META_REPOSITORY_ROOT]: '', + [WORKTREE_META_SYMLINK_PATTERNS]: '', + [WORKTREE_META_SYMLINK_SOURCE_ROOT]: '', + [WORKTREE_META_CREATION_FAILURE]: '', + [META_DIFF_BASE_BRANCH]: '', + }; + if (metadata?.worktreePath && metadata.repositoryRoot) { + const { worktreePath, repositoryRoot } = metadata; + const source = this._sessionDataService.openDatabase(sessionUri); + let baseBranch: string | undefined; + try { + baseBranch = await source.object.getMetadata(META_DIFF_BASE_BRANCH); + } finally { + source.dispose(); + } + const worktrees = await readSessionAdditionalWorktrees(this._sessionDataService, sessionUri); + const existing = worktrees.find(worktree => isEqual(URI.parse(worktree.workingDirectory), metadata.worktreePath) + && isEqual(URI.parse(worktree.repositoryRoot), metadata.repositoryRoot)); + const handle = existing?.handle ?? generateUuid(); + await this._sequencer.queue(handle, async () => { + if (!existing) { + const record = detachedWorktreeRecordUri(handle); + const ref = this._sessionDataService.openDatabase(record); + try { + await ref.object.setMetadataValues({ + [DETACHED_WORKTREE_OWNER]: sessionUri.toString(), + [DETACHED_WORKTREE_CREATED_AT]: String(Date.now()), + [DETACHED_WORKTREE_CLAIMED]: 'false', + [WORKTREE_META_BRANCH]: metadata.branchName, + [WORKTREE_META_PATH]: worktreePath.toString(), + [WORKTREE_META_REPOSITORY_ROOT]: repositoryRoot.toString(), + ...(baseBranch ? { [META_DIFF_BASE_BRANCH]: baseBranch } : {}), + ...(metadata.symlinkPatterns && metadata.symlinkSourceRoot ? { + [WORKTREE_META_SYMLINK_PATTERNS]: JSON.stringify(metadata.symlinkPatterns), + [WORKTREE_META_SYMLINK_SOURCE_ROOT]: metadata.symlinkSourceRoot.toString(), + } : {}), + }); + } finally { + ref.dispose(); + } + values[ADDITIONAL_WORKTREES_METADATA_KEY] = JSON.stringify([...worktrees, { + handle, workingDirectory: worktreePath.toString(), repositoryRoot: repositoryRoot.toString(), + }]); + } + const ref = this._sessionDataService.openDatabase(sessionUri); + try { + // Register the handle and release the original ownership in the same transaction. + await ref.object.setMetadataValues(values); + } finally { + ref.dispose(); + } + try { + await this.claimDetachedWorktree(handle); + } catch (error) { + this._logService.warn(`[${this._logLabel}:${handle}] Failed to finalize retained worktree ownership; reconciliation will retry: ${errorMessage(error)}`); + } + }); + } else { + const ref = this._sessionDataService.openDatabase(sessionUri); + try { + await ref.object.setMetadataValues(values); + } finally { + ref.dispose(); + } + } + this._materializedWorktrees.delete(sessionId); + this.takePendingAnnouncement(sessionId); + this._creationErrors.delete(sessionId); + this._worktreeDeletionRetries.delete(sessionId); + this.clearPending(sessionId); + }); + } + /** Resolves the worktree to remove before the session database is deleted. */ async prepareSessionDeletion(sessionUri: URI, sessionId: string): Promise { return this._sequencer.queue(sessionId, async () => { @@ -1226,7 +1367,7 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI return this._sequencer.queue(sessionId, () => this._removeSessionWorktree(sessionId, worktree)); } - async discardSessionWorktree(sessionUri: URI, sessionId: string, worktree: ISessionWorktree | undefined): Promise { + async discardSessionWorktree(sessionUri: URI, sessionId: string, worktree: ISessionWorktree | undefined, options?: { readonly preserveWorkingDirectory: boolean }): Promise { await this.removeSessionWorktree(sessionId, worktree); const dbRef = this._sessionDataService.openDatabase(sessionUri); try { @@ -1237,7 +1378,7 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI WORKTREE_META_SYMLINK_PATTERNS, WORKTREE_META_SYMLINK_SOURCE_ROOT, WORKTREE_META_CREATION_FAILURE, - LEGACY_WORKTREE_META_WORKING_DIRECTORY, + ...(options?.preserveWorkingDirectory ? [] : [LEGACY_WORKTREE_META_WORKING_DIRECTORY]), META_DIFF_BASE_BRANCH, ]); } finally { @@ -1734,6 +1875,7 @@ export class NullAgentHostWorktreeIsolation implements IAgentHostWorktreeIsolati async persistCreationFailure(_sessionUri: URI, _sessionId: string, _diagnostic: string | undefined): Promise { } async applyRestoreAnnouncement(_sessionUri: URI, turns: readonly Turn[]): Promise { return turns; } async prepareSessionDeletion(_sessionUri: URI, _sessionId: string): Promise { return undefined; } + async retainSessionWorktree(_sessionUri: URI, _sessionId: string): Promise { } async canAutomaticallyDeleteArchivedSession(_sessionUri: URI): Promise { return true; } async isWorktreeCleanupNeeded(_sessionUri: URI): Promise { return false; } async removeSessionWorktree(_sessionId: string, _worktree: ISessionWorktree | undefined): Promise { } diff --git a/src/vs/platform/agentHost/test/browser/agentHostConnectionsService.test.ts b/src/vs/platform/agentHost/test/browser/agentHostConnectionsService.test.ts index 664162e7e47a87..7dd931b0bf3333 100644 --- a/src/vs/platform/agentHost/test/browser/agentHostConnectionsService.test.ts +++ b/src/vs/platform/agentHost/test/browser/agentHostConnectionsService.test.ts @@ -181,6 +181,129 @@ suite('AgentHostConnectionsService', () => { assert.strictEqual(service.resolveSessionResource(URI.parse('remote-unknown-copilotcli:/foo')), undefined); }); + test('maps backend resources back through local and remote connection policy', () => { + const { service } = createService([info('remote', 'Remote')], new Map()); + assert.deepStrictEqual([ + service.getSessionResource(URI.parse('codex:/external'))?.toString(), + service.getSessionResource(URI.parse('codex:/external'), 'remote')?.toString(), + ], ['agent-host-codex:/external', 'remote-remote-codex:/external']); + }); + + test('resolves mixed legacy and standard identities by provider on the owning connection', () => { + const { service } = createService([], new Map()); + const resources = [ + [URI.parse('copilotcli:/old'), 'copilotcli'], + [URI.parse('ahp-session:/new-copilot'), 'copilotcli'], + [URI.parse('ahp-session:/new-codex'), 'codex'], + [URI.parse('claude:/same-id'), 'claude'], + [URI.parse('codex:/same-id'), 'codex'], + ] as const; + const local = resources.map(([backend, provider]) => service.registerSessionResource(backend, AMBIENT_AGENT_HOST_AUTHORITY, provider)); + const remote = service.registerSessionResource(URI.parse('ahp-session:/remote-codex'), 'myhost', 'codex'); + store.add(service.registerSessionResolutionPolicy('myhost', { connectionAddress: 'myhost' })); + assert.deepStrictEqual({ + local: local.map(resource => [resource.toString(), service.resolveSessionResourceIdentity(resource.with({ fragment: 'peer' }))?.backendSession.toString()]), + remote: [remote.toString(), service.resolveSessionResourceIdentity(remote)?.backendSession.toString()], + }, { + local: resources.map(([backend, provider]) => [`agent-host-${provider}:${backend.path}`, backend.toString()]), + remote: ['remote-myhost-codex:/remote-codex', 'ahp-session:/remote-codex'], + }); + }); + + test('declines reverse mapping for an unknown remote authority', () => { + const { service } = createService([], new Map()); + assert.strictEqual(service.getSessionResource(URI.parse('codex:/external'), 'unknown'), undefined); + }); + + test('lookup does not claim a backend identity and uses exact advertised records when available', () => { + const { service } = createService([], new Map()); + const legacy = URI.parse('codex:/same'); + const standard = URI.parse('ahp-session:/same'); + const before = [ + service.getSessionResource(legacy)?.toString(), + service.getSessionResource(standard), + service.findSessionResource(legacy), + ]; + const advertised = service.registerSessionResource(standard, undefined, 'codex'); + assert.deepStrictEqual({ + before, + standard: service.getSessionResource(standard)?.toString(), + legacy: service.getSessionResource(legacy), + backend: service.resolveSessionResourceIdentity(advertised)?.backendSession.toString(), + }, { before: ['agent-host-codex:/same', undefined, undefined], standard: advertised.toString(), legacy: undefined, backend: standard.toString() }); + }); + + for (const authority of [AMBIENT_AGENT_HOST_AUTHORITY, 'myhost']) { + for (const aliased of [false, true]) { + test(`preserves complete host-advertised session identity (${authority}, alias: ${aliased})`, () => { + const connection = fakeConnection('remote-host'); + const byAddress = new Map([['myhost', connection]]); + const { service, ambient } = createService([info('myhost', 'My Remote')], byAddress); + if (aliased) { + store.add(service.registerSessionResolutionPolicy(authority, { + sessionSchemeAlias: { ui: 'copilot', backend: 'host-session' }, + })); + } + const advertised = URI.parse('host-session://tenant.example/opaque/session%20key?revision=7#default'); + const resource = service.registerSessionResource(advertised, authority); + const resolved = service.resolveSessionResource(resource); + assert.deepStrictEqual({ + resource: resource.toString(), + backend: resolved?.backendSession.toString(), + connection: resolved?.connection, + }, { + resource: advertised.with({ scheme: `${authority === AMBIENT_AGENT_HOST_AUTHORITY ? 'agent-host-' : 'remote-myhost-'}${aliased ? 'copilot' : 'host-session'}` }).toString(), + backend: advertised.toString(), + connection: authority === AMBIENT_AGENT_HOST_AUTHORITY ? ambient : connection, + }); + byAddress.clear(); + assert.strictEqual(service.resolveSessionResourceIdentity(resource)?.backendSession.toString(), advertised.toString()); + assert.strictEqual(service.getSessionResource(advertised, authority)?.toString(), resource.with({ fragment: '' }).toString()); + }); + + test(`reverse mapping preserves parent resolution for peer selections (${authority}, alias: ${aliased})`, () => { + const connection = fakeConnection('remote-host'); + const byAddress = new Map([['myhost', connection]]); + const { service, ambient } = createService([info('myhost', 'My Remote')], byAddress); + if (aliased) { + store.add(service.registerSessionResolutionPolicy(authority, { + sessionSchemeAlias: { ui: 'copilot', backend: 'codex' }, + })); + } + const backend = URI.parse('codex:/external'); + const resource = service.getSessionResource(backend, authority)!; + const selectedPeer = resource.with({ fragment: 'peer-chat' }); + const resolved = service.resolveSessionResource(selectedPeer); + assert.deepStrictEqual({ + resource: resource.toString(), + backend: resolved?.backendSession.toString(), + connection: resolved?.connection, + }, { + resource: `${authority === AMBIENT_AGENT_HOST_AUTHORITY ? 'agent-host-' : 'remote-myhost-'}${aliased ? 'copilot' : 'codex'}:/external`, + backend: backend.toString(), + connection: authority === AMBIENT_AGENT_HOST_AUTHORITY ? ambient : connection, + }); + byAddress.clear(); + assert.strictEqual(service.resolveSessionResourceIdentity(selectedPeer)?.backendSession.toString(), backend.toString()); + }); + + test(`declines opaque identities instead of silently changing the target (${authority}, alias: ${aliased})`, () => { + const { service } = createService([info('myhost', 'My Remote')], new Map([['myhost', fakeConnection('remote')]])); + if (aliased) { + store.add(service.registerSessionResolutionPolicy(authority, { + sessionSchemeAlias: { ui: 'copilot', backend: 'codex' }, + })); + } + assert.deepStrictEqual([ + 'codex://tenant.example/session', + 'codex:/session?revision=7', + 'codex:/session#default', + 'codex://tenant.example/opaque/session%20key?revision=7#default', + ].map(value => service.getSessionResource(URI.parse(value), authority)), [undefined, undefined, undefined, undefined]); + }); + } + } + test('applies provider session resolution policy', () => { const remoteConn = fakeConnection('remote-host'); const byAddress = new Map([['myhost', remoteConn]]); diff --git a/src/vs/platform/agentHost/test/browser/webPubSubRelayTransport.test.ts b/src/vs/platform/agentHost/test/browser/webPubSubRelayTransport.test.ts index 1fce5d24128142..84edcdde6c2d80 100644 --- a/src/vs/platform/agentHost/test/browser/webPubSubRelayTransport.test.ts +++ b/src/vs/platform/agentHost/test/browser/webPubSubRelayTransport.test.ts @@ -185,7 +185,7 @@ suite('WebPubSubRelayTransport', () => { const transport = createTransport(fake, {}, logService); await connectHandshake(transport, fake); fake.onmessage?.({ data: '{"secret-token": invalid' }); - transport.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }); + transport.send({ jsonrpc: '2.0', id: 1, method: 'ping', params: {} }); await timeout(30_001); assert.deepStrictEqual(messages, [ '[WebPubSubRelayTransport] clientId=c1 durationMs=0 relay ready; joinedGroups=2', @@ -207,6 +207,130 @@ suite('WebPubSubRelayTransport', () => { assert.strictEqual(transport.isOpen, true); }); + for (const method of ['initialize', 'reconnect']) { + test(`bounds an unanswered ${method} even when Azure acknowledges publication`, () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const fake = new FakeWebSocket(); + const errors: string[] = []; + let closes = 0; + const transport = createTransport(fake, { onProtocolError: error => errors.push(String(error)) }); + store.add(transport.onClose(() => closes++)); + await connectHandshake(transport, fake); + transport.send({ jsonrpc: '2.0', id: 1, method, params: {} }); + for (const publish of fake.sentOfType('sendToGroup')) { + fake.emit({ type: 'ack', ackId: publish['ackId'], success: true }); + } + await timeout(20_000); + fake.emitGroupMessage(1, { kind: 'message', generation: 7, data: { jsonrpc: '2.0', method: 'unrelated' } }, BROADCAST); + await timeout(9999); + const before = transport.isOpen; + await timeout(1); + assert.deepStrictEqual({ before, open: transport.isOpen, closes, errors, publishes: fake.sentOfType('sendToGroup').length }, { + before: true, open: false, closes: 1, errors: ['Error: WPS host handshake timed out'], publishes: 1, + }); + })); + } + + test('a matching handshake response cancels its deadline and a later handshake gets a new one', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const fake = new FakeWebSocket(); + const transport = createTransport(fake); + await connectHandshake(transport, fake); + transport.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }); + fake.emit({ type: 'ack', ackId: fake.sentOfType('sendToGroup')[0]['ackId'], success: true }); + await timeout(29_000); + fake.emitGroupMessage(1, { kind: 'message', generation: 7, data: { jsonrpc: '2.0', id: 1, result: {} } }); + await timeout(2000); + const initialized = transport.isOpen; + transport.send({ jsonrpc: '2.0', id: 2, method: 'reconnect', params: {} }); + fake.emit({ type: 'ack', ackId: fake.sentOfType('sendToGroup')[1]['ackId'], success: true }); + await timeout(29_999); + const reconnecting = transport.isOpen; + await timeout(1); + assert.deepStrictEqual({ initialized, reconnecting, open: transport.isOpen }, { initialized: true, reconnecting: true, open: false }); + })); + + test('a progressing chunked handshake response keeps recovery alive until reassembly', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const fake = new FakeWebSocket(); + const transport = createTransport(fake); + await connectHandshake(transport, fake); + transport.send({ jsonrpc: '2.0', id: 1, method: 'reconnect', params: {} }); + fake.emit({ type: 'ack', ackId: fake.sentOfType('sendToGroup')[0]['ackId'], success: true }); + const segments = chunk({ jsonrpc: '2.0', id: 1, result: { snapshot: 'x'.repeat(1000) } }, { maxChunkBytes: 512 }); + await timeout(25_000); + for (const [index, segment] of segments.slice(0, -1).entries()) { + fake.emitGroupMessage(index + 1, { ...segment, generation: 7 }); + } + await timeout(10_000); + const receiving = transport.isOpen; + fake.emitGroupMessage(segments.length, { ...segments[segments.length - 1], generation: 7 }); + await timeout(30_000); + assert.deepStrictEqual({ receiving, open: transport.isOpen }, { receiving: true, open: true }); + transport.dispose(); + })); + + test('pins the handshake generation, discards stale frames and honors only its closure notice', async () => { + const fake = new FakeWebSocket(); + const transport = createTransport(fake); + const received: ProtocolMessage[] = []; + store.add(transport.onMessage(message => received.push(message))); + await connectHandshake(transport, fake); + transport.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { clientId: 'c1', protocolVersions: ['0.9.0'] } }); + fake.emitGroupMessage(1, { kind: 'message', generation: 12, data: { jsonrpc: '2.0', id: 1, result: {} } }); + fake.emitGroupMessage(2, { kind: 'message', generation: 11, data: { jsonrpc: '2.0', method: 'stale' } }, BROADCAST); + fake.emit({ type: 'message', from: 'group', group: TO_CLIENT, dataType: 'json', data: { kind: 'closed', generation: 11 } }); + fake.emit({ type: 'message', from: 'group', group: BROADCAST, dataType: 'json', data: { kind: 'closed', generation: 12 } }); + assert.strictEqual(transport.isOpen, true); + assert.strictEqual(received.length, 1); + fake.emit({ type: 'message', from: 'group', group: TO_CLIENT, dataType: 'json', data: { kind: 'closed', generation: 12 } }); + assert.strictEqual(fake.closed, true); + }); + + test('a later-generation answer to an outstanding request detects a lost closure notice', async () => { + const fake = new FakeWebSocket(); + const transport = createTransport(fake); + await connectHandshake(transport, fake); + transport.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { clientId: 'c1', protocolVersions: ['0.9.0'] } }); + fake.emitGroupMessage(1, { kind: 'message', generation: 1, data: { jsonrpc: '2.0', id: 1, result: {} } }); + transport.send({ jsonrpc: '2.0', id: 2, method: 'ping', params: {} }); + fake.emitGroupMessage(2, { kind: 'message', generation: 2, data: { jsonrpc: '2.0', id: 2, result: {} } }); + assert.strictEqual(fake.closed, true); + }); + + test('a previous connection closure cannot end the pending reconnect handshake', async () => { + const fake = new FakeWebSocket(); + const transport = createTransport(fake); + const messages: ProtocolMessage[] = []; + store.add(transport.onMessage(message => messages.push(message))); + await connectHandshake(transport, fake); + transport.send({ jsonrpc: '2.0', id: 1, method: 'reconnect', params: { clientId: 'c1', subscriptions: [], lastSeenServerSeq: 0 } }); + fake.emit({ type: 'message', from: 'group', group: TO_CLIENT, dataType: 'json', data: { kind: 'closed', generation: 11 } }); + fake.emitGroupMessage(1, { kind: 'message', generation: 11, data: { jsonrpc: '2.0', id: 99, result: {} } }); + fake.emitGroupMessage(2, { kind: 'message', generation: 12, data: { jsonrpc: '2.0', id: 1, error: { code: -32008, message: 'Not found' } } }); + assert.strictEqual(transport.isOpen, true); + assert.strictEqual(messages.length, 1); + fake.emit({ type: 'message', from: 'group', group: TO_CLIENT, dataType: 'json', data: { kind: 'closed', generation: 12 } }); + assert.strictEqual(fake.closed, true); + }); + + test('a handshake response from an already closed connection cannot establish the transport', async () => { + const fake = new FakeWebSocket(); + const transport = createTransport(fake); + await connectHandshake(transport, fake); + transport.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { clientId: 'c1', protocolVersions: ['0.9.0'] } }); + fake.emit({ type: 'message', from: 'group', group: TO_CLIENT, dataType: 'json', data: { kind: 'closed', generation: 11 } }); + fake.emitGroupMessage(1, { kind: 'message', generation: 11, data: { jsonrpc: '2.0', id: 1, result: {} } }); + assert.strictEqual(fake.closed, true); + }); + + test('delivers batch items in order and accepts legacy envelopes without a generation', async () => { + const fake = new FakeWebSocket(); + const transport = createTransport(fake); + const received: ProtocolMessage[] = []; + store.add(transport.onMessage(message => received.push(message))); + await connectHandshake(transport, fake); + fake.emit({ type: 'message', from: 'group', group: TO_CLIENT, dataType: 'json', data: { kind: 'batch', items: [{ jsonrpc: '2.0', id: 1, result: {} }, { jsonrpc: '2.0', id: 2, result: {} }] } }); + assert.deepStrictEqual(received.map(message => hasKey(message, { id: true }) ? message.id : undefined), [1, 2]); + }); + test('rejects the connect when a joinGroup ack reports failure', async () => { const fake = new FakeWebSocket(); const transport = createTransport(fake); @@ -512,6 +636,44 @@ suite('WebPubSubRelayTransport', () => { }); } + test('refuses plaintext credentials before publishing when sealed authentication is required', async () => { + const socket = new FakeWebSocket(); + const transport = createTransport(socket); + await connectHandshake(transport, socket); + assert.throws(() => transport.send({ + jsonrpc: '2.0', id: 1, method: 'authenticate', params: { channel: 'ahp-root://', resource: 'https://api.github.com', token: 'plaintext-test-credential' }, + }), /Refusing to send plaintext/); + assert.deepStrictEqual(socket.sentOfType('sendToGroup'), []); + }); + + test('redacts sealed authentication from the WPS transcript without changing wire messages', async () => { + const fileService = store.add(new FileService(new NullLogService())); + store.add(fileService.registerProvider('file', store.add(new InMemoryFileSystemProvider()))); + const logger = store.add(new AhpJsonlLogger( + { logsHome: URI.file('/logs'), logId: 'relay', connectionId: 'client', transport: 'webpubsub' }, + fileService, new NullLogService(), + )); + const socket = new FakeWebSocket(); + const transport = createTransport(socket, { ahpLogger: logger }); + await connectHandshake(transport, socket); + const request: JsonRpcRequest = { + jsonrpc: '2.0', id: 1, method: 'authenticate', + params: { channel: 'ahp-root://', resource: 'https://api.github.com', token: 'copilot-sealed.v1.test.replayable-ciphertext' }, + }; + transport.send(request); + await logger.flush(); + const transcript = (await fileService.readFile(logger.resource)).value.toString(); + assert.deepStrictEqual({ + ciphertextLogged: transcript.includes('replayable-ciphertext'), + redacted: transcript.includes('[REDACTED]'), + published: socket.sentOfType('sendToGroup')[0]['data'], + }, { + ciphertextLogged: false, + redacted: true, + published: { kind: 'message', data: request }, + }); + }); + test('publishes outbound messages to the to_host lane as sendToGroup frames', async () => { const fake = new FakeWebSocket(); const transport = createTransport(fake); diff --git a/src/vs/platform/agentHost/test/common/agentHostConfigurationSync.test.ts b/src/vs/platform/agentHost/test/common/agentHostConfigurationSync.test.ts index 146adc86730920..107a8a00021118 100644 --- a/src/vs/platform/agentHost/test/common/agentHostConfigurationSync.test.ts +++ b/src/vs/platform/agentHost/test/common/agentHostConfigurationSync.test.ts @@ -11,11 +11,13 @@ import { Registry } from '../../../registry/common/platform.js'; import '../../../request/common/request.js'; import { AgentHostConfigurationSyncTarget, formatAgentHostConfigurationSyncValueForLog, getAgentHostConfigurationSyncEntries, getAgentHostConfigurationSyncTarget, getGlobalConfigurationValue, inspectValue, resolveAgentHostConfigurationSyncPatch } from '../../common/agentHostConfigurationSync.js'; import { LOCAL_AGENT_HOST_RESOURCE_IDENTITY } from '../../common/agentHostResourceService.js'; +import { AgentHostByokModelsEnabledConfigKey, AgentHostByokModelsEnabledDefault } from '../../common/agentHostSchema.js'; import { artifactToolsConfigurationProperties } from '../../common/artifactToolsConfiguration.js'; const ALL_HOSTS_SETTING = 'test.agentHostSync.allHosts'; const LOCAL_SETTING = 'test.agentHostSync.local'; const AMBIENT_SETTING = 'test.agentHostSync.ambient'; +const BYOK_SETTING = 'test.agentHostSync.byok'; const HIDDEN_SETTING = 'test.agentHostSync.hidden'; const UNSYNCED_SETTING = 'test.agentHostSync.unsynced'; const ENUM_SETTING = 'test.agentHostSync.enum'; @@ -58,6 +60,11 @@ suite('AgentHostConfigurationSync', () => { default: false, agentHost: { key: 'ambientValue', scope: AgentHostConfigurationSyncScope.Ambient }, }, + [BYOK_SETTING]: { + type: 'boolean' as const, + default: AgentHostByokModelsEnabledDefault, + agentHost: { key: AgentHostByokModelsEnabledConfigKey, scope: AgentHostConfigurationSyncScope.Local }, + }, [HIDDEN_SETTING]: { type: 'boolean' as const, default: false, @@ -173,6 +180,21 @@ suite('AgentHostConfigurationSync', () => { }); }); + test('mirrors BYOK enabled by default while preserving explicit opt-out', () => { + const defaultPatch = resolveAgentHostConfigurationSyncPatch(createConfigurationService({}), AgentHostConfigurationSyncTarget.Local); + const disabledPatch = resolveAgentHostConfigurationSyncPatch(createConfigurationService({ + [BYOK_SETTING]: { userValue: false }, + }), AgentHostConfigurationSyncTarget.Local); + + assert.deepStrictEqual({ + defaultValue: defaultPatch[AgentHostByokModelsEnabledConfigKey], + explicitlyDisabled: disabledPatch[AgentHostByokModelsEnabledConfigKey], + }, { + defaultValue: true, + explicitlyDisabled: false, + }); + }); + test('applies local and ambient scopes to the corresponding host targets', () => { const configurationService = createConfigurationService({ [ALL_HOSTS_SETTING]: { defaultValue: true }, diff --git a/src/vs/platform/agentHost/test/common/agentHostOperatingSystem.test.ts b/src/vs/platform/agentHost/test/common/agentHostOperatingSystem.test.ts index e0a3647f11be55..44a39d1ce873f1 100644 --- a/src/vs/platform/agentHost/test/common/agentHostOperatingSystem.test.ts +++ b/src/vs/platform/agentHost/test/common/agentHostOperatingSystem.test.ts @@ -10,6 +10,8 @@ import { mock } from '../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { getAgentHostOperatingSystem } from '../../common/agentHostOperatingSystem.js'; import type { IAgentConnection, IAgentHostNetworkDiagnosticsInfo } from '../../common/agentService.js'; +import type { IAgentSubscription } from '../../common/state/agentSubscription.js'; +import type { RootState } from '../../common/state/sessionState.js'; suite('AgentHostOperatingSystem', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -18,9 +20,12 @@ suite('AgentHostOperatingSystem', () => { return { version: '1.0.0', os, arch: 'x64', proxySettings: {}, proxyEnv: {}, endpoints: [] }; } - function createConnection(getInfo: () => Promise) { + function createConnection(getInfo: () => Promise, meta?: RootState['_meta']) { return new class extends mock() { override readonly clientId = 'test-client'; + override readonly rootState = new class extends mock>() { + override readonly value: RootState = { agents: [], activeSessions: 0, _meta: meta }; + }(); requestCount = 0; override async getNetworkDiagnosticsInfo(): Promise { @@ -30,6 +35,32 @@ suite('AgentHostOperatingSystem', () => { }(); } + for (const [platform, expected] of [ + ['windows', OperatingSystem.Windows], + ['macos', OperatingSystem.Macintosh], + ['linux', OperatingSystem.Linux], + ] as const) { + test(`uses advertised ${platform} without requiring a diagnostics extension`, async () => { + const connection = createConnection(async () => { throw new Error('Method not found: getNetworkDiagnosticsInfo'); }, { + 'vscode.agentHost.resources': { platform }, + }); + assert.deepStrictEqual({ + os: await getAgentHostOperatingSystem(connection), + requestCount: connection.requestCount, + }, { os: expected, requestCount: 0 }); + }); + } + + test('invalid advertised platform retains the existing diagnostics fallback', async () => { + const connection = createConnection(async () => diagnostics('linux'), { + 'vscode.agentHost.resources': { platform: 'unsupported' }, + }); + assert.deepStrictEqual({ + os: await getAgentHostOperatingSystem(connection), + requestCount: connection.requestCount, + }, { os: OperatingSystem.Linux, requestCount: 1 }); + }); + for (const [platform, expected] of [ ['win32', OperatingSystem.Windows], ['darwin', OperatingSystem.Macintosh], diff --git a/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts b/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts index 73428ef836385e..e2d60e20666c72 100644 --- a/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts +++ b/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import type { IConfigurationValue } from '../../../configuration/common/configuration.js'; -import { AgentHostAgentOrchestrationLimitsConfigKey, AgentHostAutoAttachPullRequestsConfigKey, AgentHostCanvasesEnabledConfigKey, AgentHostGitHubMcpServerEnabledConfigKey, AgentHostMarkdownPlanRichLinksEnabledConfigKey, createSchema, migrateLegacyAutopilotConfig, normalizeAgentHostTerminalAutoApproveRulesConfig, platformRootSchema, platformSessionSchema, schemaProperty, type AgentHostTerminalAutoApproveRules, type AutoApproveLevel, type IPermissionsValue, type SessionMode } from '../../common/agentHostSchema.js'; +import { AgentHostActiveAgentTitleGenerationConfigKey, AgentHostAgentOrchestrationLimitsConfigKey, AgentHostAutoAttachPullRequestsConfigKey, AgentHostByokModelsEnabledConfigKey, AgentHostCanvasesEnabledConfigKey, AgentHostDeferredTitleGenerationConfigKey, AgentHostGitHubMcpServerEnabledConfigKey, AgentHostMarkdownPlanRichLinksEnabledConfigKey, createSchema, migrateLegacyAutopilotConfig, normalizeAgentHostTerminalAutoApproveRulesConfig, platformRootSchema, platformSessionSchema, schemaProperty, type AgentHostTerminalAutoApproveRules, type AutoApproveLevel, type IPermissionsValue, type SessionMode } from '../../common/agentHostSchema.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import type { IShellInitScript } from '../../common/shellInitScript.js'; import { JsonRpcErrorCodes, ProtocolError } from '../../common/state/sessionProtocol.js'; @@ -31,6 +31,23 @@ suite('agentHostSchema', () => { ensureNoDisposablesAreLeakedInTestSuite(); + test('automatic title generation root settings are disabled by default', () => { + const properties = platformRootSchema.toProtocol().properties; + assert.deepStrictEqual({ + activeAgent: { + type: properties[AgentHostActiveAgentTitleGenerationConfigKey].type, + default: properties[AgentHostActiveAgentTitleGenerationConfigKey].default, + }, + deferred: { + type: properties[AgentHostDeferredTitleGenerationConfigKey].type, + default: properties[AgentHostDeferredTitleGenerationConfigKey].default, + }, + }, { + activeAgent: { type: 'boolean', default: false }, + deferred: { type: 'boolean', default: false }, + }); + }); + test('Markdown plan rich links are an additive boolean root setting', () => { const property = platformRootSchema.toProtocol().properties[AgentHostMarkdownPlanRichLinksEnabledConfigKey]; assert.strictEqual(property.type, 'boolean'); @@ -54,6 +71,11 @@ suite('agentHostSchema', () => { assert.strictEqual(property.default, true); }); + test('BYOK models are enabled by default', () => { + const property = platformRootSchema.toProtocol().properties[AgentHostByokModelsEnabledConfigKey]; + assert.deepStrictEqual({ type: property.type, default: property.default }, { type: 'boolean', default: true }); + }); + test('Canvases are an additive disabled-by-default root setting', () => { const property = platformRootSchema.toProtocol().properties[AgentHostCanvasesEnabledConfigKey]; assert.deepStrictEqual({ type: property.type, default: property.default }, { type: 'boolean', default: false }); diff --git a/src/vs/platform/agentHost/test/common/agentHostSessionIdentity.test.ts b/src/vs/platform/agentHost/test/common/agentHostSessionIdentity.test.ts new file mode 100644 index 00000000000000..16b9adf8c7fc32 --- /dev/null +++ b/src/vs/platform/agentHost/test/common/agentHostSessionIdentity.test.ts @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { AgentSession } from '../../common/agent.js'; +import { getAgentHostChatId } from '../../common/agentHostChatIdentity.js'; +import { newAgentHostSessionUri } from '../../common/agentHostSessionIdentity.js'; +import { AgentHostSessionUrisCapabilityMetaKey, supportsAgentHostSessionUris } from '../../common/meta/agentHostSessionUrisMeta.js'; +import { buildChatUri, buildDefaultChatUri, getSessionChatResource } from '../../common/state/sessionState.js'; +import type { InitializeResult } from '../../common/state/sessionProtocol.js'; + +suite('Agent Host session identity', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + for (const provider of ['copilotcli', 'codex', 'claude']) { + test(`creation negotiation preserves ${provider} legacy hosts without requiring extensions on standard hosts`, () => { + const hosts: InitializeResult[] = [ + { protocolVersion: '0.9.0', serverSeq: 0, _meta: { 'vscode.agentHost': true } }, + { protocolVersion: '0.9.0', serverSeq: 0, _meta: { 'vscode.agentHost': true, [AgentHostSessionUrisCapabilityMetaKey]: 'true' } }, + { protocolVersion: '0.9.0', serverSeq: 0, _meta: { [AgentHostSessionUrisCapabilityMetaKey]: true } }, + { protocolVersion: '0.9.0', serverSeq: 0 }, + ].map(host => ({ ...host, snapshots: [] })); + assert.deepStrictEqual(hosts.map(host => ({ + supports: supportsAgentHostSessionUris(host), + session: newAgentHostSessionUri(provider, 'fresh', host).toString(), + })), [ + { supports: false, session: `${provider}:/fresh` }, + { supports: false, session: `${provider}:/fresh` }, + { supports: true, session: 'ahp-session:/fresh' }, + { supports: false, session: 'ahp-session:/fresh' }, + ]); + }); + } + + test('historical hostBuild identifies native hosts without comparing product versions', () => { + const host = { protocolVersion: '0.9.0', serverSeq: 0, snapshots: [] }; + assert.deepStrictEqual([ + newAgentHostSessionUri('claude', 'new', host, { agents: [], _meta: { hostBuild: { version: 'any-version' } } }).scheme, + newAgentHostSessionUri('claude', 'new', host, { agents: [], _meta: { hostBuild: { version: 9 } } }).scheme, + newAgentHostSessionUri('claude', 'new', { ...host, _meta: { 'vscode.getAgentHostSessionStateFile.chat': true } }).scheme, + ], ['claude', 'ahp-session', 'ahp-session']); + }); + + test('standard session URIs never imply a provider and native chats retain their full owning resource', () => { + const legacy = URI.parse('codex:/old'); + const standard = URI.parse('ahp-session:/fresh'); + assert.deepStrictEqual({ + providers: [AgentSession.provider(legacy), AgentSession.provider(standard)], + chats: [legacy, standard].map(session => [ + getAgentHostChatId(buildDefaultChatUri(session)), + getAgentHostChatId(buildChatUri(session, 'peer')), + ]), + distinct: buildDefaultChatUri(legacy) !== buildDefaultChatUri(standard), + }, { providers: ['codex', undefined], chats: [['default', 'peer'], ['default', 'peer']], distinct: true }); + }); + + test('opaque advertised default and peer chat resources survive frontend fragment adaptation', () => { + const state = { defaultChat: 'conversation://tenant/default?revision=3', chats: [{ resource: 'conversation://tenant/peer?revision=4' }] }; + const fragment = getAgentHostChatId(state.chats[0].resource); + assert.deepStrictEqual([ + getSessionChatResource(state, 'default'), + getSessionChatResource(state, fragment), + ], [state.defaultChat, state.chats[0].resource]); + }); +}); diff --git a/src/vs/platform/agentHost/test/common/agentImageGenerationMeta.test.ts b/src/vs/platform/agentHost/test/common/agentImageGenerationMeta.test.ts new file mode 100644 index 00000000000000..acc4ae89122f20 --- /dev/null +++ b/src/vs/platform/agentHost/test/common/agentImageGenerationMeta.test.ts @@ -0,0 +1,62 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { imageGenerationToolMetaKey, parseImageGenerationToolMetadata, readImageGenerationToolMetadata } from '../../common/meta/agentImageGenerationMeta.js'; +import { readToolCallMeta, readToolCallPresentation, toToolCallMeta } from '../../common/meta/agentToolCallMeta.js'; +import { ToolCallConfirmationReason, ToolCallStatus } from '../../common/state/protocol/state.js'; + +suite('Image generation tool metadata', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('round trips namespaced image identity without interpreting model identifiers', () => { + const imageGeneration = { requestedModel: { id: 'remote-provider/image:preview', name: 'Image Preview' } }; + const source = { _meta: toToolCallMeta({ toolKind: 'read', [imageGenerationToolMetaKey]: imageGeneration }) }; + assert.deepStrictEqual({ + image: readImageGenerationToolMetadata(source), + all: readToolCallMeta(source), + conversationModelOnly: readImageGenerationToolMetadata({ _meta: { model: 'gpt-5.5' } }), + }, { + image: imageGeneration, + all: { toolKind: 'read', [imageGenerationToolMetaKey]: imageGeneration }, + conversationModelOnly: undefined, + }); + }); + + test('rejects missing and malformed identity and tolerates absent display names', () => { + const values = [ + undefined, null, [], 'image', {}, + { requestedModel: null }, + { requestedModel: { name: 'Not an identity' } }, + { requestedModel: { id: 42 } }, + { requestedModel: { id: ' ' } }, + ]; + assert.deepStrictEqual(values.map(parseImageGenerationToolMetadata), values.map(() => undefined)); + assert.deepStrictEqual([undefined, '', ' ', 42].map(name => parseImageGenerationToolMetadata({ + requestedModel: { id: 'image-preview', name }, + })), Array.from({ length: 4 }, () => ({ requestedModel: { id: 'image-preview' } }))); + }); + + test('image metadata selects the VS Code convention before tool-name fallbacks', () => { + const values = [{ requestedModel: { id: 'remote-provider/image:preview' } }, { requestedModel: { id: '' } }]; + const presentations = values.map(value => readToolCallPresentation({ + status: ToolCallStatus.Completed, + toolCallId: 'image-call', + toolName: 'grep', + displayName: 'grep', + invocationMessage: 'Running grep', + pastTenseMessage: 'Tool finished', + confirmed: ToolCallConfirmationReason.NotNeeded, + success: true, + _meta: { [imageGenerationToolMetaKey]: value }, + })); + assert.deepStrictEqual(presentations, values.map(() => ({ + toolKind: undefined, + invocationMessage: 'Running grep', + pastTenseMessage: 'Tool finished', + }))); + }); +}); diff --git a/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts b/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts index a2f54c305e6172..01c2ff1b05a817 100644 --- a/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts +++ b/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts @@ -13,7 +13,7 @@ import { readSlashCommandResource, toSlashCommandResourceMeta } from '../../comm import { readChatInputState, withChatInputState } from '../../common/meta/agentHostChatInputState.js'; import { createEditorInlineChatInstruction, createTerminalChatInstruction, readChatSurfaceMeta, withChatSurfaceMeta } from '../../common/meta/agentChatSurfaceMeta.js'; import { readAgentCustomizationMeta, toAgentCustomizationMeta } from '../../common/meta/agentCustomizationMeta.js'; -import { readMcpServerDisplayName, readMcpServerSource, withMcpServerDisplayNameMeta, withMcpServerSourceMeta } from '../../common/meta/mcpCustomizationMeta.js'; +import { readMcpServerControllingSetting, readMcpServerDisplayName, readMcpServerSource, readMcpServerSourcePlugin, withMcpServerControllingSettingMeta, withMcpServerDisplayNameMeta, withMcpServerSourceMeta, withMcpServerSourcePluginMeta } from '../../common/meta/mcpCustomizationMeta.js'; import { getCommandArgumentHint, getCompletionAction, readCompletionAttachmentMeta, toCommandCompletionAttachmentMeta, toSkillCompletionAttachmentMeta } from '../../common/meta/agentCompletionAttachmentMeta.js'; import { CustomizationType, MessageAttachmentKind, ToolCallStatus, hasReportedUsage, readSessionComparisonMetadata, readUsageInfoMeta, withSessionComparisonMetadata, type AgentCustomization, type ClientPluginCustomization, type ToolCallState, type UsageInfo } from '../../common/state/sessionState.js'; import { McpServerStatus, type McpServerCustomization, type SessionModelInfo, type SimpleMessageAttachment } from '../../common/state/protocol/state.js'; @@ -166,6 +166,37 @@ suite('Agent host _meta readers', () => { }); }); + test('validates MCP source plugins and controlling settings and removes them once they no longer apply', () => { + const customization = (meta: Record | undefined): McpServerCustomization => ({ + type: CustomizationType.McpServer, + id: 'server', + uri: 'mcp-top-level:server', + name: 'computer-use', + state: { kind: McpServerStatus.Ready }, + _meta: meta, + }); + const opaque = { 'test.opaque': 'kept' }; + const recorded = withMcpServerControllingSettingMeta(withMcpServerSourcePluginMeta(opaque, 'computer-use'), 'chat.example.enabled'); + + assert.deepStrictEqual({ + read: [readMcpServerSourcePlugin(customization(recorded)), readMcpServerControllingSetting(customization(recorded))], + invalidPlugins: [undefined, '', ' ', 1, {}].map(value => readMcpServerSourcePlugin(customization({ 'agentHost.mcpServerSourcePlugin': value }))), + invalidSettings: [undefined, '', 1, {}].map(value => readMcpServerControllingSetting(customization({ 'vscode.mcpServerControllingSetting': value }))), + recorded, + cleared: withMcpServerControllingSettingMeta(withMcpServerSourcePluginMeta(recorded, undefined), undefined), + emptied: withMcpServerSourcePluginMeta({ 'agentHost.mcpServerSourcePlugin': 'computer-use' }, undefined), + unchanged: withMcpServerSourcePluginMeta(opaque, undefined) === opaque && withMcpServerSourcePluginMeta(recorded, 'computer-use') === recorded, + }, { + read: ['computer-use', 'chat.example.enabled'], + invalidPlugins: [undefined, undefined, undefined, undefined, undefined], + invalidSettings: [undefined, undefined, undefined, undefined], + recorded: { 'test.opaque': 'kept', 'agentHost.mcpServerSourcePlugin': 'computer-use', 'vscode.mcpServerControllingSetting': 'chat.example.enabled' }, + cleared: { 'test.opaque': 'kept' }, + emptied: undefined, + unchanged: true, + }); + }); + suite('readToolCallMeta', () => { test('returns empty when no _meta', () => { assert.deepStrictEqual(readToolCallMeta(toolCall(undefined)), {}); diff --git a/src/vs/platform/agentHost/test/common/agentService.test.ts b/src/vs/platform/agentHost/test/common/agentService.test.ts index 69c70425fdba56..38f7adbfa7efed 100644 --- a/src/vs/platform/agentHost/test/common/agentService.test.ts +++ b/src/vs/platform/agentHost/test/common/agentService.test.ts @@ -6,9 +6,10 @@ import assert from 'assert'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { IConfigurationService } from '../../../configuration/common/configuration.js'; +import { ConfigurationTarget, IConfigurationChangeEvent, IConfigurationService } from '../../../configuration/common/configuration.js'; +import { thirdPartyAgentEnabledValue } from '../../../policy/common/copilotManagedSettings.js'; import { AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, GITHUB_REPO_PROTECTED_RESOURCE, protectedResourcesRequireGitHubCopilotSignIn } from '../../common/agent.js'; -import { AgentHostClaudeAgentEnabledSettingId, AgentHostCodexAgentEnabledSettingId, AgentHostOTelEnvVars, AgentHostOTelPolicyState, buildAgentHostOTelEnv, CodexPreferAgentHostEditorSettingId, isAgentEnabled, readAgentHostOTelPolicySettings, sanitizeAgentHostOTelPolicySettings, shouldSurfaceLocalAgentHostProvider } from '../../common/agentService.js'; +import { affectsAgentHostProviderPreference, AgentHostClaudeAgentEnabledSettingId, AgentHostCodexAgentEnabledSettingId, AgentHostOTelEnvVars, AgentHostOTelPolicyState, buildAgentHostOTelEnv, CodexPreferAgentHostEditorSettingId, isAgentEnabled, readAgentHostOTelPolicySettings, sanitizeAgentHostOTelPolicySettings, shouldSurfaceLocalAgentHostProvider } from '../../common/agentService.js'; import type { ProtectedResourceMetadata } from '../../common/state/protocol/state.js'; import { buildChatUri, buildDefaultChatUri, resolveChatUri } from '../../common/state/sessionState.js'; import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; @@ -38,6 +39,29 @@ suite('AgentSession namespace', () => { const session = AgentSession.uri('copilot', 'sess-1'); assert.strictEqual(AgentSession.provider(session), 'copilot'); }); + + test('native URI helpers preserve legacy schemes without assigning a provider to standard sessions', () => { + const session = AgentSession.uri('copilotcli', 'mc-test'); + assert.deepStrictEqual({ + session: session.toString(), + id: AgentSession.id(session), + provider: AgentSession.provider(session), + stringProvider: AgentSession.provider(session.toString()), + legacyProvider: AgentSession.provider('copilotcli:/existing-session'), + standardProvider: AgentSession.provider('ahp-session:/standard-session'), + claude: AgentSession.uri('claude', 'unchanged').toString(), + codex: AgentSession.uri('codex', 'unchanged').toString(), + }, { + session: 'copilotcli:/mc-test', + id: 'mc-test', + provider: 'copilotcli', + stringProvider: 'copilotcli', + legacyProvider: 'copilotcli', + standardProvider: undefined, + claude: 'claude:/unchanged', + codex: 'codex:/unchanged', + }); + }); }); suite('isAgentEnabled', () => { @@ -110,7 +134,7 @@ suite('shouldSurfaceLocalAgentHostProvider', () => { }); }); - test('hides disabled providers in their governed windows', () => { + test('hides disabled providers in both windows even when the editor prefers Codex', () => { const configurationService = new TestConfigurationService({ [AgentHostClaudeAgentEnabledSettingId]: false, [AgentHostCodexAgentEnabledSettingId]: false, @@ -126,8 +150,55 @@ suite('shouldSurfaceLocalAgentHostProvider', () => { agentsClaude: false, editorClaude: false, agentsCodex: false, - editorCodex: true, + editorCodex: false, + }); + }); + + test('reacts to Codex enablement changes in both windows', () => { + const event: IConfigurationChangeEvent = { + source: ConfigurationTarget.DEFAULT, + affectedKeys: new Set([AgentHostCodexAgentEnabledSettingId]), + change: { keys: [AgentHostCodexAgentEnabledSettingId], overrides: [] }, + affectsConfiguration: key => key === AgentHostCodexAgentEnabledSettingId, + }; + assert.deepStrictEqual({ + agentsWindow: affectsAgentHostProviderPreference(event, true), + editorWindow: affectsAgentHostProviderPreference(event, false), + }, { agentsWindow: true, editorWindow: true }); + }); + + test('preserves editor preference and restores availability when policy is removed', async () => { + const configurationService = new TestConfigurationService({ + [AgentHostCodexAgentEnabledSettingId]: true, + [CodexPreferAgentHostEditorSettingId]: false, }); + const before = [true, false].map(window => shouldSurfaceLocalAgentHostProvider('codex', configurationService, window)); + await configurationService.setUserConfiguration(AgentHostCodexAgentEnabledSettingId, false); + const governed = [true, false].map(window => shouldSurfaceLocalAgentHostProvider('codex', configurationService, window)); + await configurationService.setUserConfiguration(AgentHostCodexAgentEnabledSettingId, true); + const restored = [true, false].map(window => shouldSurfaceLocalAgentHostProvider('codex', configurationService, window)); + assert.deepStrictEqual({ before, governed, restored }, { + before: [true, false], + governed: [false, false], + restored: [true, false], + }); + }); + + test('managed-settings and preview-feature denials suppress both harnesses on every surface', () => { + for (const policyData of [ + { managedSettingsActive: true }, + { chat_preview_features_enabled: false }, + ]) { + const configurationService = new TestConfigurationService({ + [AgentHostClaudeAgentEnabledSettingId]: thirdPartyAgentEnabledValue(policyData) ?? true, + [AgentHostCodexAgentEnabledSettingId]: thirdPartyAgentEnabledValue(policyData) ?? true, + [CodexPreferAgentHostEditorSettingId]: true, + }); + assert.deepStrictEqual({ + editor: ['claude', 'codex', 'copilot'].map(provider => shouldSurfaceLocalAgentHostProvider(provider, configurationService, false)), + agents: ['claude', 'codex', 'copilot'].map(provider => shouldSurfaceLocalAgentHostProvider(provider, configurationService, true)), + }, { editor: [false, false, true], agents: [false, false, true] }); + } }); }); diff --git a/src/vs/platform/agentHost/test/common/codexAccount.test.ts b/src/vs/platform/agentHost/test/common/codexAccount.test.ts index 446a49877f9408..cb0ea12156a8e8 100644 --- a/src/vs/platform/agentHost/test/common/codexAccount.test.ts +++ b/src/vs/platform/agentHost/test/common/codexAccount.test.ts @@ -24,6 +24,7 @@ suite('Codex account metadata', () => { status: 'signedIn', email: 'person@example.com', planType: undefined, + observedAt: undefined, profileImage: undefined, requiresOpenaiAuth: undefined, rateLimit: { usedPercent: 42.4, windowDurationMins: 10080, resetsAt: 1234 }, @@ -33,6 +34,15 @@ suite('Codex account metadata', () => { }); }); + test('retains only valid observation timestamps without refreshing them on read', () => { + const timestamps = [0, 1234, -1, Infinity, NaN, '1234', undefined]; + assert.deepStrictEqual(timestamps.map(observedAt => readCodexAccountInfo({ + agents: [], _meta: { + [CODEX_ACCOUNT_META_KEY]: { status: 'signedIn', observedAt }, + } + }).observedAt), [0, 1234, undefined, undefined, undefined, undefined, undefined]); + }); + test('drops malformed rate-limit metadata', () => { const account = readCodexAccountInfo({ agents: [], diff --git a/src/vs/platform/agentHost/test/common/codexSessionModel.test.ts b/src/vs/platform/agentHost/test/common/codexSessionModel.test.ts new file mode 100644 index 00000000000000..ba271f1a231150 --- /dev/null +++ b/src/vs/platform/agentHost/test/common/codexSessionModel.test.ts @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CODEX_SESSION_MODEL_META_KEY, readCodexSessionModel, withCodexSessionModel } from '../../common/meta/codexSessionModel.js'; + +suite('Codex session model metadata', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('round-trips canonical provider model ids', () => { + const model = { id: '@provider=openai:gpt-5.6-sol' }; + const meta = withCodexSessionModel({ other: true }, model); + + assert.deepStrictEqual({ meta, model: readCodexSessionModel({ _meta: meta }) }, { + meta: { other: true, [CODEX_SESSION_MODEL_META_KEY]: model }, + model, + }); + }); + + test('fails closed for absent, malformed, legacy, and non-canonical values', () => { + const values = [ + undefined, + { [CODEX_SESSION_MODEL_META_KEY]: true }, + { [CODEX_SESSION_MODEL_META_KEY]: {} }, + { [CODEX_SESSION_MODEL_META_KEY]: { id: 'gpt-5.6-sol' } }, + { [CODEX_SESSION_MODEL_META_KEY]: { id: '@provider=openai:' } }, + { [CODEX_SESSION_MODEL_META_KEY]: { id: '@provider=%6fpenai:gpt-5.6-sol' } }, + ]; + + assert.deepStrictEqual(values.map(_meta => readCodexSessionModel({ _meta })), values.map(() => undefined)); + }); + + test('removes invalid or missing selections without dropping other metadata', () => { + const meta = { other: true, [CODEX_SESSION_MODEL_META_KEY]: { id: '@provider=openai:gpt' } }; + + assert.deepStrictEqual([ + withCodexSessionModel(meta, undefined), + withCodexSessionModel(meta, { id: 'legacy-model' }), + withCodexSessionModel({ [CODEX_SESSION_MODEL_META_KEY]: { id: '@provider=openai:gpt' } }, undefined), + ], [{ other: true }, { other: true }, undefined]); + }); +}); diff --git a/src/vs/platform/agentHost/test/common/meta/relayConnectionMeta.test.ts b/src/vs/platform/agentHost/test/common/meta/relayConnectionMeta.test.ts new file mode 100644 index 00000000000000..172b4734e2459c --- /dev/null +++ b/src/vs/platform/agentHost/test/common/meta/relayConnectionMeta.test.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. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { isPassiveRelayConnection, readRelayKeepAliveTimeout } from '../../../common/meta/relayConnectionMeta.js'; + +suite('Relay connection metadata', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('optional metadata cannot silently widen passive connections or create invalid timers', () => { + assert.deepStrictEqual([undefined, {}, { _meta: { 'copilot.passive': 'true' } }, { _meta: { 'copilot.passive': true } }] + .map(isPassiveRelayConnection), [false, false, false, true]); + assert.deepStrictEqual([undefined, '30000', 0, -1, 1.5, Infinity, Number.MAX_SAFE_INTEGER + 1, 900_000] + .map(value => readRelayKeepAliveTimeout({ _meta: { 'copilot.keepAliveTimeoutMs': value } })), [undefined, undefined, undefined, undefined, undefined, undefined, undefined, 900_000]); + }); +}); diff --git a/src/vs/platform/agentHost/test/common/openSessionLink.test.ts b/src/vs/platform/agentHost/test/common/openSessionLink.test.ts index 4e2790738eac26..4e2c89313770bb 100644 --- a/src/vs/platform/agentHost/test/common/openSessionLink.test.ts +++ b/src/vs/platform/agentHost/test/common/openSessionLink.test.ts @@ -6,13 +6,36 @@ import assert from 'assert'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { AGENT_HOST_CHAT_LINK_PATTERN, AGENT_HOST_SESSION_ONLY_LINK_PATTERN, buildAgentSessionLinkPresentation, buildExternalOpenSessionLinkUri, buildOpenSessionLinkForChatResource, buildOpenSessionLinkUri, isCreateChatTool, isCreateSessionTool, isSendMessageTool, parseExternalOpenSessionLinkUri, parseOpenSessionLinkChatId, parseOpenSessionLinkTurnId, parseOpenSessionLinkUri } from '../../common/openSessionLink.js'; +import { AGENT_HOST_CHAT_LINK_PATTERN, AGENT_HOST_SESSION_ONLY_LINK_PATTERN, buildAgentSessionLinkPresentation, buildExternalOpenSessionLinkUri, buildOpenSessionLinkForChatResource, buildOpenSessionLinkUri, isCreateChatTool, isCreateSessionTool, isSendMessageTool, parseExternalOpenSessionLinkUri, parseOpenSessionLinkChatId, parseOpenSessionLinkConnectionAuthority, parseOpenSessionLinkTurnId, parseOpenSessionLinkUri } from '../../common/openSessionLink.js'; import { buildChatUri, buildDefaultChatUri } from '../../common/state/sessionState.js'; suite('openSessionLink', () => { ensureNoDisposablesAreLeakedInTestSuite(); + test('connection-scoped links preserve backend and peer identities through product handoff', () => { + const backend = URI.parse('ahp-session://tenant/opaque?revision=2'); + const link = buildOpenSessionLinkUri(backend, 'conversation:/peer', 'turn-1', 'other-host'); + const external = buildExternalOpenSessionLinkUri('vscode-insiders', backend, 'conversation:/peer', 'turn-1', 'other-host'); + const restored = parseExternalOpenSessionLinkUri(external, 'vscode-insiders'); + assert.ok(restored); + assert.deepStrictEqual([link, restored].map(value => ({ + backend: parseOpenSessionLinkUri(value)?.toString(), + authority: parseOpenSessionLinkConnectionAuthority(value), + chat: parseOpenSessionLinkChatId(value), + turn: parseOpenSessionLinkTurnId(value), + })), Array(2).fill({ backend: backend.toString(), authority: 'other-host', chat: 'conversation:/peer', turn: 'turn-1' })); + }); + + test('invalid declared connection scope cannot fall back to the ambient host', () => { + assert.deepStrictEqual(['', '%'].map(value => parseOpenSessionLinkUri(`agent-host-session://ahp-session/shared?connectionAuthority=${value}`)), [undefined, undefined]); + }); + + test('an explicitly supplied ambient scope remains distinguishable from old unscoped links', () => { + const backend = URI.parse('ahp-session:/shared'); + assert.deepStrictEqual([undefined, 'local'].map(authority => parseOpenSessionLinkConnectionAuthority(buildOpenSessionLinkUri(backend, undefined, undefined, authority))), [undefined, 'local']); + }); + test('isCreateSessionTool matches bare and mcp-prefixed names', () => { assert.strictEqual(isCreateSessionTool('create_session'), true); assert.strictEqual(isCreateSessionTool('mcp__server__create_session'), true); @@ -41,6 +64,25 @@ suite('openSessionLink', () => { assert.strictEqual(parsed?.toString(), URI.parse(backend).toString()); }); + test('round-trips standard and opaque backend identities without deriving providers', () => { + const sessions = ['ahp-session:/new', 'conversation://tenant/opaque/session?revision=3#state']; + assert.deepStrictEqual(sessions.map(session => ({ + internal: parseOpenSessionLinkUri(buildOpenSessionLinkUri(session))?.toString(), + external: parseOpenSessionLinkUri(parseExternalOpenSessionLinkUri(buildExternalOpenSessionLinkUri('vscode-insiders', session), 'vscode-insiders')!)?.toString(), + })), sessions.map(session => ({ internal: URI.parse(session).toString(), external: URI.parse(session).toString() }))); + }); + + test('preserves advertised session schemes rather than applying native provider aliases', () => { + const sessions = ['copilotcli:/same-id', 'ahp-session:/same-id', 'custom-provider:/same-id']; + assert.deepStrictEqual(sessions.map(session => ({ + link: buildOpenSessionLinkUri(session), + restored: parseOpenSessionLinkUri(buildOpenSessionLinkUri(session))?.toString(), + })), sessions.map(session => ({ + link: `agent-host-session://${URI.parse(session).scheme}/same-id`, + restored: session, + }))); + }); + test('builds and parses an external Agents window session link', () => { const external = buildExternalOpenSessionLinkUri('vscode-insiders', 'copilotcli:/abc-123', 'chat-9', 'turn-7'); assert.deepStrictEqual({ diff --git a/src/vs/platform/agentHost/test/common/pendingEditContentUri.test.ts b/src/vs/platform/agentHost/test/common/pendingEditContentUri.test.ts new file mode 100644 index 00000000000000..3a8488dc8a1471 --- /dev/null +++ b/src/vs/platform/agentHost/test/common/pendingEditContentUri.test.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 assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { buildPendingEditContentUri, parsePendingEditContentUri } from '../../common/pendingEditContentUri.js'; + +suite('PendingEditContentUri', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('identifies the session of a proposed edit', () => { + const session = 'ahp-session:/known-session'; + const uri = buildPendingEditContentUri(session, 'tool/id', '/workspace/space and ünicode.txt'); + assert.deepStrictEqual(parsePendingEditContentUri(uri.toString()), { sessionUri: session }); + }); + + test('rejects malformed or non-content references', () => { + const valid = buildPendingEditContentUri('ahp-session:/known-session', 'tool', '/file'); + for (const uri of [ + valid.with({ scheme: 'file' }), + valid.with({ authority: 'invalid' }), + valid.with({ authority: '123' }), + valid.with({ authority: '' }), + valid.with({ path: '/tool/not-hex' }), + valid.with({ path: '/tool/abcd/extra' }), + valid.with({ path: '//abcd' }), + valid.with({ query: 'extra' }), + valid.with({ fragment: 'extra' }), + ]) { + assert.strictEqual(parsePendingEditContentUri(uri.toString()), undefined); + } + }); +}); diff --git a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts index de2ef406dfaa61..5aef690ac65c87 100644 --- a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts +++ b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts @@ -723,6 +723,7 @@ export function createNoopGitStateService(): IAgentHostGitStateService { onDidChangeSessionGitHubState: Event.None, refreshSessionGitState: async (_sessionKey: string, _workingDirectory?: URI) => { }, getMaterializedWorktreeMeta: (_sessionKey: string, _branchName: string) => undefined, + setFolderGitState: async () => { }, resolveSessionBaseBranchName: async (_sessionKey: string) => undefined, setSessionGitHubState: async (_sessionKey: string, _state: ISessionGitHubState) => { }, recordSessionMerge: async (_sessionKey: string, _commit: string) => { }, diff --git a/src/vs/platform/agentHost/test/common/titleGenerationConfiguration.test.ts b/src/vs/platform/agentHost/test/common/titleGenerationConfiguration.test.ts new file mode 100644 index 00000000000000..404ef51881d64a --- /dev/null +++ b/src/vs/platform/agentHost/test/common/titleGenerationConfiguration.test.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 assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { ConfigurationScope } from '../../../configuration/common/configurationRegistry.js'; +import { AgentHostDeferredTitleGenerationConfigKey } from '../../common/agentHostSchema.js'; +import { AgentHostDeferredTitleGenerationSettingId } from '../../common/agentService.js'; +import { titleGenerationConfigurationProperties } from '../../common/titleGenerationConfiguration.js'; + +suite('TitleGenerationConfiguration', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('deferred title generation is default-off with experiment and host sync support', () => { + const property = titleGenerationConfigurationProperties[AgentHostDeferredTitleGenerationSettingId]; + + assert.deepStrictEqual({ + type: property.type, + default: property.default, + scope: property.scope, + experiment: property.experiment, + agentHost: property.agentHost, + }, { + type: 'boolean', + default: false, + scope: ConfigurationScope.APPLICATION, + experiment: { mode: 'auto' }, + agentHost: { key: AgentHostDeferredTitleGenerationConfigKey }, + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/common/webPubSub/framing.test.ts b/src/vs/platform/agentHost/test/common/webPubSub/framing.test.ts index f0604b89aa9b90..e04fb23dbe0aff 100644 --- a/src/vs/platform/agentHost/test/common/webPubSub/framing.test.ts +++ b/src/vs/platform/agentHost/test/common/webPubSub/framing.test.ts @@ -77,6 +77,39 @@ suite('WebPubSub - framing', () => { assert.strictEqual(first.kind, 'pending'); }); + test('stamps every fragment without exceeding its serialized ceiling', () => { + let ack = 0; + const frames = buildPublish({ group: GROUP, nextAckId: () => ++ack, payload: 'x'.repeat(4096), generation: Number.MAX_SAFE_INTEGER, chunkOptions: { maxChunkBytes: 512 } }); + const reassembler = new Reassembler(); + for (const frame of frames) { + assert.strictEqual(frame.data.generation, Number.MAX_SAFE_INTEGER); + assert.ok(new TextEncoder().encode(JSON.stringify(frame.data)).byteLength <= 512); + } + const values = frames.map(frame => parseInbound({ type: 'message', from: 'group', group: GROUP, dataType: 'json', data: frame.data }, { reassembler })); + assert.deepStrictEqual(values.at(-1), { kind: 'payload', group: { scope: 'client', lane: 'to-client', uid: 'u1', eid: 'e1', cid: 'c1' }, payload: 'x'.repeat(4096), generation: Number.MAX_SAFE_INTEGER }); + }); + + test('decodes batches, receiver capabilities and closure notices independently of AHP', () => { + const parse = (data: object) => parseInbound({ type: 'message', from: 'group', group: GROUP, dataType: 'json', data }, { reassembler: new Reassembler() }); + const group = { scope: 'client', lane: 'to-client', uid: 'u1', eid: 'e1', cid: 'c1' }; + assert.deepStrictEqual(parse({ kind: 'batch', items: [{ id: 1 }, { id: 2 }], generation: 7 }), { kind: 'batch', group, payloads: [{ id: 1 }, { id: 2 }], generation: 7 }); + assert.deepStrictEqual(parse({ kind: 'capabilities', accepts: ['batch', 'future'] }), { kind: 'capabilities', group, accepts: ['batch', 'future'] }); + assert.deepStrictEqual(parse({ kind: 'closed', generation: 7 }), { kind: 'closed', group, generation: 7 }); + for (const data of [{ kind: 'batch', items: [] }, { kind: 'batch', items: Array(257).fill({}) }, { kind: 'message', data: {}, generation: 0 }, { kind: 'closed', generation: Number.MAX_SAFE_INTEGER + 1 }]) { + assert.throws(() => parse(data), FramingError); + } + }); + + test('rejects fragments that try to mix host connection generations', () => { + const reassembler = new Reassembler(); + let ack = 0; + const frames = buildPublish({ group: GROUP, nextAckId: () => ++ack, payload: 'x'.repeat(4096), generation: 1, chunkOptions: { maxChunkBytes: 512 } }); + const parse = (data: object) => parseInbound({ type: 'message', from: 'group', group: GROUP, dataType: 'json', data }, { reassembler }); + assert.strictEqual(parse(frames[0].data).kind, 'pending'); + assert.throws(() => parse({ ...frames[1].data, generation: 2 }), /generation mismatch/); + assert.strictEqual(reassembler.inFlightGroupCount, 0); + }); + test('ignores non group-fanout frames', () => { const reassembler = new Reassembler(); assert.strictEqual(parseInbound({ type: 'ack', ackId: 1 }, { reassembler }).kind, 'ignored'); diff --git a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts index 78cb76c9e0cc96..367852b36f8e44 100644 --- a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts @@ -26,6 +26,7 @@ import { agentHostAuthority, toAgentHostUri } from '../../common/agentHostUri.js import { AgentHostFileSystemProvider } from '../../common/agentHostFileSystemProvider.js'; import { AgentHostPermissionMode, AgentHostResourceIdentity, AgentHostResourcePermissionError, IAgentHostResourceService, LOCAL_AGENT_HOST_RESOURCE_IDENTITY } from '../../common/agentHostResourceService.js'; import { buildAnnotationsUri } from '../../common/annotationsUri.js'; +import { CODEX_SESSION_MODEL_META_KEY, withCodexSessionModel } from '../../common/meta/codexSessionModel.js'; import { ConfigurationTarget, type IConfigurationValue } from '../../../configuration/common/configuration.js'; import { ContentEncoding, ReconnectResultType } from '../../common/state/protocol/commands.js'; import { ChatSourceKind } from '../../common/state/protocol/channels-chat/commands.js'; @@ -37,7 +38,7 @@ import { chatReducer } from '../../common/state/sessionReducers.js'; import { ProtocolError, type AhpServerNotification, type JsonRpcNotification, type JsonRpcRequest, type JsonRpcResponse, type ProtocolMessage } from '../../common/state/sessionProtocol.js'; import { hasKey } from '../../../../base/common/types.js'; import { mainWindow } from '../../../../base/browser/window.js'; -import { AUTOMATION_CATALOG_URI, buildChatUri, createChatState, createSessionState, CustomizationType, MessageAttachmentKind, MessageKind, PendingMessageKind, readSessionExternal, readSessionWorkspaceless, ROOT_STATE_URI, SessionStatus, StateComponents, TurnState, customizationId, withSessionExternal, withSessionWorkspaceless, type ChatState, type SessionState } from '../../common/state/sessionState.js'; +import { AUTOMATION_CATALOG_URI, buildChatUri, buildDefaultChatUri, createChatState, createSessionState, CustomizationType, MessageAttachmentKind, MessageKind, PendingMessageKind, readSessionExternal, readSessionWorkspaceless, ROOT_STATE_URI, SessionStatus, StateComponents, TurnState, customizationId, withSessionExternal, withSessionWorkspaceless, type ChatState, type SessionState } from '../../common/state/sessionState.js'; import { AgentHostTransportFailureReason, NonReconnectableTransportError, type IClientTransport, type IProtocolTransport, type ITransportCloseDetails } from '../../common/state/sessionTransport.js'; import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; import { ITelemetryService, TelemetryConfiguration, TelemetryLevel, TELEMETRY_SETTING_ID } from '../../../telemetry/common/telemetry.js'; @@ -91,6 +92,7 @@ const syncTestConfigurationNode = { }; import type { Implementation } from '../../common/state/protocol/common/commands.js'; import { agentsWindowAgentHostClientInfo, editorWindowAgentHostClientInfo } from '../../common/agentHostClientInfo.js'; +import { AgentHostSessionUrisCapabilityMetaKey } from '../../common/meta/agentHostSessionUrisMeta.js'; import { AgentHostClientConnectionKind } from '../../common/agentHostTelemetry.js'; import type { IRemoteAgentHostReconnectPolicy } from '../../common/reconnectPolicy.js'; import { IWorkspaceTrustEnablementService, IWorkspaceTrustManagementService, IWorkspaceTrustRequestService, type ResourceTrustRequestOptions } from '../../../workspace/common/workspaceTrust.js'; @@ -428,6 +430,81 @@ suite('AgentHostProtocolClient', () => { await connectPromise; } + test('creation addressing negotiates native host skew without requiring an extension on conforming hosts', async () => { + const results = []; + for (const provider of ['copilotcli', 'codex', 'claude']) { + for (const meta of [ + { 'vscode.agentHost': true }, + { 'vscode.agentHost': true, [AgentHostSessionUrisCapabilityMetaKey]: 'true' }, + { [AgentHostSessionUrisCapabilityMetaKey]: true }, + {}, + ]) { + const { client, transport } = createClient(); + await connectClient(client, transport, meta); + const creating = client.createSession({ provider }); + await flushMicrotasks(); + const sent = transport.sentMessages.findLast((message): message is JsonRpcRequest => hasKey(message, { method: true, id: true }) && message.method === 'createSession' && typeof message.id === 'number'); + assert.ok(sent); + const params = sent.params as { channel: string; provider: string }; + transport.fireMessage({ jsonrpc: '2.0', id: sent.id, result: null }); + const session = await creating; + results.push([params.provider, URI.parse(params.channel).scheme, session.toString() === params.channel]); + } + } + assert.deepStrictEqual(results, ['copilotcli', 'codex', 'claude'].flatMap(provider => [ + [provider, provider, true], [provider, provider, true], [provider, 'ahp-session', true], [provider, 'ahp-session', true], + ])); + }); + + test('a historical native root snapshot selects legacy creation at the client boundary', async () => { + const results = []; + for (const provider of ['copilotcli', 'codex', 'claude']) { + const { client, transport } = createClient(); + const connecting = client.connect(); + await flushMicrotasks(); + const initialize = transport.sentMessages[0] as JsonRpcRequest; + transport.fireMessage({ + jsonrpc: '2.0', id: initialize.id, + result: { + protocolVersion: '0.9.0', serverSeq: 0, + snapshots: [{ resource: ROOT_STATE_URI, fromSeq: 0, state: { agents: [], _meta: { hostBuild: { version: 'older-native-build' } } } }], + }, + }); + await connecting; + const creating = client.createSession({ provider }); + await flushMicrotasks(); + const sent = transport.sentMessages.findLast((message): message is JsonRpcRequest => hasKey(message, { method: true, id: true }) && message.method === 'createSession' && typeof message.id === 'number'); + assert.ok(sent); + const params = sent.params as { channel: string; provider: string }; + transport.fireMessage({ jsonrpc: '2.0', id: sent.id, result: null }); + const resource = await creating; + results.push([params.provider, resource.scheme, resource.toString() === params.channel]); + } + assert.deepStrictEqual(results, ['copilotcli', 'codex', 'claude'].map(provider => [provider, provider, true])); + }); + test('relay keep-alive continues during sixteen minutes of uninterrupted inbound traffic', () => runWithFakedTimers({ useFakeTimers: true, maxTaskCount: 10_000 }, async () => { + const transport = disposables.add(new TestProtocolTransport(AgentHostClientConnectionKind.WebPubSub)); + const { client } = createClient(transport, undefined, { hasHighLoad: () => false }); + try { + await connectClient(client, transport, { 'copilot.keepAliveTimeoutMs': 90_000 }); + let answered = 0; + for (let second = 0; second < 16 * 60; second++) { + transport.fireMessage({ jsonrpc: '2.0', id: -1, result: null }); + await timeout(1000); + const requests = transport.sentMessages.filter(message => hasKey(message, { method: true }) && message.method === 'ping'); + for (const request of requests.slice(answered)) { + assert.ok(hasKey(request, { id: true })); + transport.fireMessage({ jsonrpc: '2.0', id: request.id, result: null }); + } + answered = requests.length; + } + assert.strictEqual(answered, 32); + assert.strictEqual(client.connectionState, AgentHostClientState.Connected); + } finally { + client.dispose(); + } + })); + test('Dev Container facade is capability gated for old and malformed hosts', async () => { const supported: boolean[] = []; for (const meta of [undefined, { 'vscode.devContainers': 'true' }, { 'vscode.devContainers': false }, getAgentHostExtensionInitializeResultMeta(true, true)]) { @@ -592,6 +669,7 @@ suite('AgentHostProtocolClient', () => { const initialize = transport.sentMessages[0] as JsonRpcRequest; assert.deepStrictEqual((initialize.params as { _meta?: Record })._meta, { + [AgentHostSessionUrisCapabilityMetaKey]: true, 'vscode.clientConnectionKind': AgentHostClientConnectionKind.RemoteExtensionHost, 'vscode.telemetryLevel': 'all', 'vscode.clientMachineId': 'client-machine-id', @@ -610,6 +688,7 @@ suite('AgentHostProtocolClient', () => { const noTelemetryConnectPromise = noTelemetryClient.connect(); const noTelemetryInitialize = noTelemetryTransport.sentMessages[0] as JsonRpcRequest; assert.deepStrictEqual((noTelemetryInitialize.params as { _meta?: Record })._meta, { + [AgentHostSessionUrisCapabilityMetaKey]: true, 'vscode.telemetryLevel': 'off', }); noTelemetryTransport.fireMessage({ @@ -738,6 +817,54 @@ suite('AgentHostProtocolClient', () => { }]); }); + test('unmarked conforming hosts retain opaque session and chat resources through listing, subscription and dispatch', async () => { + const { client, transport } = createClient(); + await connectClient(client, transport); + const session = 'session-store://tenant/sessions/one?generation%3D2'; + const chat = 'conversation://tenant/history/one?revision%3D3#entry'; + const listing = client.listSessions(); + const listRequest = transport.sentMessages.findLast((message): message is JsonRpcRequest => hasKey(message, { method: true, id: true }) && message.method === 'listSessions'); + assert.ok(listRequest); + transport.fireMessage({ + jsonrpc: '2.0', id: listRequest.id, + result: { + items: [{ + resource: session, provider: 'third-party', title: 'Conversation', status: SessionStatus.Idle, + createdAt: new Date(1000).toISOString(), modifiedAt: new Date(2000).toISOString(), + defaultChat: chat, chats: [{ resource: chat, title: 'Default' }], + }] + }, + }); + const [metadata] = await listing; + const subscription = disposables.add(client.getSubscription(StateComponents.Chat, URI.parse(chat), 'test')); + await flushMicrotasks(); + const subscribeRequest = transport.sentMessages.findLast((message): message is JsonRpcRequest => hasKey(message, { method: true, id: true }) && message.method === 'subscribe'); + assert.ok(subscribeRequest); + transport.fireMessage({ + jsonrpc: '2.0', id: subscribeRequest.id, + result: { snapshot: { resource: chat, fromSeq: 0, state: createChatState({ resource: chat, title: 'Default', status: SessionStatus.Idle, modifiedAt: new Date(2000).toISOString() }) } }, + }); + await flushMicrotasks(); + client.dispatch(chat, { type: ActionType.SessionTitleChanged, title: 'Renamed' }); + const dispatch = transport.sentMessages.findLast((message): message is JsonRpcNotification => hasKey(message, { method: true }) && message.method === 'dispatchAction'); + assert.ok(dispatch); + const state = subscription.object.value; + assert.ok(state && !(state instanceof Error)); + assert.deepStrictEqual({ + session: metadata.session.toString(), provider: metadata.provider, meta: metadata._meta, + chats: metadata.chats?.map(entry => [entry.chat.toString(), entry.kind]), + subscribe: subscribeRequest.params, + stateResource: state.resource, + dispatchChannel: (dispatch.params as { channel: string }).channel, + }, { + session, provider: 'third-party', meta: undefined, + chats: [[chat, 'default']], + subscribe: { channel: chat }, + stateResource: chat, + dispatchChannel: chat, + }); + }); + test('chat read-state actions require AHP 0.10', () => { const action = { type: ActionType.ChatIsReadChanged, isRead: true } as const; assert.deepStrictEqual({ @@ -808,6 +935,38 @@ suite('AgentHostProtocolClient', () => { assert.deepStrictEqual(sessions.map(s => readSessionExternal(s._meta)), [true]); }); + test('listSessions reads only valid provider-qualified Codex models from namespaced metadata', async () => { + const { client, transport } = createClient(); + const resultPromise = client.listSessions(); + const sent = transport.sentMessages[0] as JsonRpcRequest; + const summary = (id: string, _meta?: Record) => ({ + resource: `agent-session://codex/${id}`, + provider: 'codex', + title: id, + status: SessionStatus.Idle, + createdAt: new Date(1000).toISOString(), + modifiedAt: new Date(2000).toISOString(), + ...(_meta ? { _meta } : {}), + }); + transport.fireMessage({ + jsonrpc: '2.0', + id: sent.id, + result: { + items: [ + summary('valid', withCodexSessionModel(undefined, { id: '@provider=openai:gpt-5.6-sol' })), + summary('malformed', { [CODEX_SESSION_MODEL_META_KEY]: { id: 'gpt-5.6-sol' } }), + summary('absent'), + ], + }, + }); + + assert.deepStrictEqual((await resultPromise).map(session => session.model), [ + { id: '@provider=openai:gpt-5.6-sol' }, + undefined, + undefined, + ]); + }); + test('listSessions preserves client-addressed remote working directories across reload', async () => { const { client, transport } = createClient(); const remoteDirectory = URI.parse('vscode-remote://ssh-remote+host/workspace'); @@ -1561,6 +1720,7 @@ suite('AgentHostProtocolClient', () => { clientId: 'renderer-client-id', clientInfo, _meta: { + [AgentHostSessionUrisCapabilityMetaKey]: true, 'vscode.clientConnectionKind': 'dev_tunnel', 'vscode.telemetryLevel': 'all', 'vscode.clientMachineId': 'client-machine-id', @@ -1628,6 +1788,7 @@ suite('AgentHostProtocolClient', () => { assert.deepStrictEqual((initialize.params as { _meta?: Record })._meta, { 'vscode.clientConnectionKind': AgentHostClientConnectionKind.RemoteExtensionHost, 'vscode.telemetryLevel': 'off', + [AgentHostSessionUrisCapabilityMetaKey]: true, }); transport.fireMessage({ jsonrpc: '2.0', @@ -3059,6 +3220,131 @@ suite('AgentHostProtocolClient', () => { suite('soft reconnect (transport factory)', () => { + test('WPS root refresh has a bounded hold and ignores a late response after timeout', async () => { + const clock = sinon.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }); + try { + const transport = disposables.add(new TestProtocolTransport(AgentHostClientConnectionKind.WebPubSub)); + const { client } = createClient(transport); + const connecting = client.connect(); + const initialize = await waitForRequest(transport, 'initialize'); + transport.fireMessage({ + jsonrpc: '2.0', id: initialize.id, + result: { protocolVersion: PROTOCOL_VERSION, serverSeq: 1, snapshots: [{ resource: ROOT_STATE_URI, fromSeq: 1, state: { agents: [], activeSessions: 0 } }] }, + }); + await connecting; + const work = client.authenticate({ resource: 'https://api.github.com', token: 'copilot-sealed.v1.test.ciphertext' }); + const rejected = assert.rejects(work, /subscribe response timed out/); + let finished = false; + void work.then(() => { finished = true; }, () => { finished = true; }); + const authentication = await waitForRequest(transport, 'authenticate'); + transport.fireMessage({ jsonrpc: '2.0', id: authentication.id, result: {} }); + const rootSubscription = await waitForRequest(transport, 'subscribe'); + await clock.tickAsync(20_000); + transport.fireMessage({ + jsonrpc: '2.0', method: 'action', + params: { channel: ROOT_STATE_URI, serverSeq: 3, origin: undefined, action: { type: ActionType.RootActiveSessionsChanged, activeSessions: 4 } }, + }); + await clock.tickAsync(9_999); + assert.strictEqual(finished, false); + await clock.tickAsync(1); + await rejected; + transport.fireMessage({ + jsonrpc: '2.0', id: rootSubscription.id, + result: { snapshot: { resource: ROOT_STATE_URI, fromSeq: 2, state: { agents: [], activeSessions: 0 } } }, + }); + const root = client.rootState.value; + assert.ok(root && !(root instanceof Error)); + assert.strictEqual(root.activeSessions, 4); + } finally { + clock.restore(); + } + }); + + test('WPS authenticates before refreshing its minimal root snapshot and never forwards host configuration', async () => { + const transport = disposables.add(new TestProtocolTransport(AgentHostClientConnectionKind.WebPubSub)); + const workspaceTrust = createWorkspaceTrustServices(); + const trustedFoldersChanged = disposables.add(new Emitter()); + workspaceTrust.management.onDidChangeTrustedFolders = trustedFoldersChanged.event; + const configurationService = new TestConfigurationService(); + const client = disposables.add(new AgentHostProtocolClient( + 'test.example:1234', transport, + { resolveInitialAuthentication: async () => ({ resource: 'https://api.github.com', token: 'copilot-sealed.v1.test.ciphertext' }) }, + new NullLogService(), createPermissionService(), configurationService, NullTelemetryService, + workspaceTrustEnablementService, workspaceTrust.management, workspaceTrust.request, + )); + const connecting = client.connect(); + const initialize = await waitForRequest(transport, 'initialize'); + transport.fireMessage({ + jsonrpc: '2.0', id: initialize.id, result: { + protocolVersion: PROTOCOL_VERSION, serverSeq: 1, + snapshots: [{ resource: ROOT_STATE_URI, fromSeq: 1, state: { agents: [] } }], + }, + }); + const authentication = await waitForRequest(transport, 'authenticate'); + assert.strictEqual(findRequest(transport, 'subscribe'), undefined); + transport.fireMessage({ jsonrpc: '2.0', id: authentication.id, result: {} }); + const rootSubscription = await waitForRequest(transport, 'subscribe'); + transport.fireMessage({ + jsonrpc: '2.0', method: 'action', params: { + channel: ROOT_STATE_URI, serverSeq: 4, origin: undefined, action: { + type: ActionType.RootAgentsChanged, + agents: [{ provider: 'updated-provider', displayName: 'Updated', description: '', models: [] }], + }, + }, + }); + transport.fireMessage({ + jsonrpc: '2.0', id: rootSubscription.id, result: { + snapshot: { + resource: ROOT_STATE_URI, fromSeq: 3, + state: { agents: [], config: { schema: { type: 'object', properties: {} }, values: { hostOwned: 'preserved' } } }, + }, + }, + }); + await connecting; + await configurationService.setUserConfiguration(SYNC_SETTING_A, false); + fireConfigurationChange(configurationService, SYNC_SETTING_A); + await configurationService.setUserConfiguration(TELEMETRY_SETTING_ID, TelemetryConfiguration.OFF); + fireConfigurationChange(configurationService, TELEMETRY_SETTING_ID); + await configurationService.setUserConfiguration(GLOBAL_AUTO_APPROVE_SETTING_ID, false); + fireConfigurationChange(configurationService, GLOBAL_AUTO_APPROVE_SETTING_ID); + trustedFoldersChanged.fire(); + await flushMicrotasks(); + const root = client.rootState.value; + assert.ok(root && !(root instanceof Error)); + assert.deepStrictEqual({ + connection: client.connectionState, + rootValues: root.config?.values, + providers: root.agents.map(agent => agent.provider), + configDispatch: findDispatchAction(transport, ActionType.RootConfigChanged), + managedPermissions: findNotification(transport, 'setClientManagedSettingsPermissions'), + }, { + connection: AgentHostClientState.Connected, + rootValues: { hostOwned: 'preserved' }, + providers: ['updated-provider'], + configDispatch: undefined, + managedPermissions: undefined, + }); + const firstRefresh = client.authenticate({ resource: 'https://api.github.com', token: 'copilot-sealed.v1.test.refreshed' }); + const secondRefresh = client.authenticate({ resource: 'https://mcp.example.test', token: 'copilot-sealed.v1.test.mcp' }); + const firstAuthentication = await waitForRequestAt(transport, 'authenticate', 1); + const secondAuthentication = await waitForRequestAt(transport, 'authenticate', 2); + transport.fireMessage({ jsonrpc: '2.0', id: firstAuthentication.id, result: {} }); + transport.fireMessage({ jsonrpc: '2.0', id: secondAuthentication.id, result: {} }); + const sharedRootRefresh = await waitForRequestAt(transport, 'subscribe', 1); + await Promise.resolve(); + assert.strictEqual(transport.sentMessages.filter(message => hasKey(message, { method: true, id: true }) && message.method === 'subscribe').length, 2); + transport.fireMessage({ + jsonrpc: '2.0', method: 'action', params: { + channel: ROOT_STATE_URI, serverSeq: 5, origin: undefined, action: { type: ActionType.RootActiveSessionsChanged, activeSessions: 2 }, + }, + }); + transport.fireMessage({ jsonrpc: '2.0', id: sharedRootRefresh.id, error: { code: JsonRpcErrorCodes.InternalError, message: 'Root refresh failed' } }); + await Promise.all([assert.rejects(firstRefresh, /Root refresh failed/), assert.rejects(secondRefresh, /Root refresh failed/)]); + const retained = client.rootState.value; + assert.ok(retained && !(retained instanceof Error)); + assert.strictEqual(retained.activeSessions, 2); + }); + function findRequest(transport: TestProtocolTransport, method: string): JsonRpcRequest | undefined { return transport.sentMessages.find( (m): m is JsonRpcRequest => 'method' in m && (m as JsonRpcRequest).method === method && 'id' in m, @@ -3187,6 +3473,41 @@ suite('AgentHostProtocolClient', () => { await connectPromise; } + test('refreshes the brokered ticket before opening a replacement transport', async () => { + const prepared = new DeferredPromise(); + const release = new DeferredPromise(); + const { client, transports } = createFactoryClient(undefined, undefined, undefined, { autoRestore: true, initialDelayMs: 1, maxDelayMs: 1, maxAttempts: 1 }, undefined, async () => { + await prepared.complete(); + await release.p; + }); + await completeHandshake(transports[0], client.connect()); + transports[0].fireClose(); + await prepared.p; + assert.strictEqual(transports.length, 1); + await release.complete(); + const recovered = await waitForTransport(transports, 1); + recovered.connectDeferred.complete(); + const request = await waitForRequest(recovered, 'reconnect'); + recovered.fireMessage({ jsonrpc: '2.0', id: request.id, result: { type: ReconnectResultType.Replay, actions: [], missing: [] } }); + await waitForConnectedWithin(client); + assert.strictEqual(transports.length, 2); + }); + + test('disposing during ticket preparation cannot create another transport', async () => { + const prepared = new DeferredPromise(); + const release = new DeferredPromise(); + const { client, transports } = createFactoryClient(undefined, undefined, undefined, { autoRestore: true, initialDelayMs: 1, maxDelayMs: 1, maxAttempts: 1 }, undefined, async () => { + await prepared.complete(); + await release.p; + }); + await completeHandshake(transports[0], client.connect()); + transports[0].fireClose(); + await prepared.p; + client.dispose(); + await release.complete(); + await timeout(0); + assert.strictEqual(transports.length, 1); + }); async function beginRecovery(client: AgentHostProtocolClient, transports: TestClientProtocolTransport[]) { const index = transports.length; transports[index - 1].fireClose(); @@ -3199,6 +3520,65 @@ suite('AgentHostProtocolClient', () => { return { transport, request }; } + for (const recovery of ['replay', 'snapshot', 'freshInitialize'] as const) { + for (const supported of [false, true]) { + test(`session addressing survives ${recovery} and only new allocations use renegotiated support (${supported})`, async () => { + const { client, transports } = createFactoryClient(); + const meta = { 'vscode.agentHost': true, [AgentHostSessionUrisCapabilityMetaKey]: supported }; + await completeHandshake(transports[0], client.connect(), meta); + const originalCreation = client.createSession({ provider: 'codex' }); + const createRequest = await waitForRequestAtWithin(transports[0], 'createSession', 0); + transports[0].fireMessage({ jsonrpc: '2.0', id: createRequest.id, result: null }); + const original = await originalCreation; + const originalDefaultChat = buildDefaultChatUri(original); + const state = { provider: 'codex', lifecycle: 'ready', defaultChat: originalDefaultChat }; + const ref = disposables.add(client.getSubscription<{ defaultChat: string }>(StateComponents.Session, original, 'test')); + const subscribe = await waitForRequestAtWithin(transports[0], 'subscribe', 0); + transports[0].fireMessage({ jsonrpc: '2.0', id: subscribe.id, result: { snapshot: { resource: original.toString(), fromSeq: 5, state } } }); + await flushMicrotasks(); + + const { transport, request } = await beginRecovery(client, transports); + const snapshots = [{ resource: original.toString(), fromSeq: 5, state }]; + let initializeSubscriptions: string[] | undefined; + const reconnected = Event.toPromise(Event.filter(client.onDidChangeConnectionState, value => value === AgentHostClientState.Connected)); + if (recovery === 'freshInitialize') { + transport.fireMessage({ jsonrpc: '2.0', id: request.id, error: { code: AhpErrorCodes.NotFound, message: 'client not found' } }); + const initialize = await waitForRequestAtWithin(transport, 'initialize', 0); + initializeSubscriptions = (initialize.params as { initialSubscriptions: string[] }).initialSubscriptions; + transport.fireMessage({ + jsonrpc: '2.0', id: initialize.id, + result: { protocolVersion: PROTOCOL_VERSION, serverSeq: 5, snapshots: snapshots.filter(snapshot => initializeSubscriptions?.includes(snapshot.resource)), _meta: { ...meta, [AgentHostSessionUrisCapabilityMetaKey]: !supported } }, + }); + } else { + transport.fireMessage({ + jsonrpc: '2.0', id: request.id, + result: recovery === 'replay' ? { type: ReconnectResultType.Replay, actions: [], missing: [] } : { type: ReconnectResultType.Snapshot, snapshots }, + }); + } + await reconnected; + const nextCreation = client.createSession({ provider: 'codex' }); + const nextRequest = await waitForRequestAtWithin(transport, 'createSession', 0); + transport.fireMessage({ jsonrpc: '2.0', id: nextRequest.id, result: null }); + const next = await nextCreation; + const retained = ref.object.value; + assert.ok(retained && !(retained instanceof Error)); + assert.deepStrictEqual({ + originalScheme: original.scheme, + retainedDefaultChat: retained.defaultChat, + reconnectSubscriptions: (request.params as { subscriptions: string[] }).subscriptions, + initializeSubscriptions, + nextScheme: next.scheme, + }, { + originalScheme: supported ? 'ahp-session' : 'codex', + retainedDefaultChat: originalDefaultChat, + reconnectSubscriptions: [ROOT_STATE_URI, original.toString()], + initializeSubscriptions: recovery === 'freshInitialize' ? [ROOT_STATE_URI, original.toString()] : undefined, + nextScheme: (recovery === 'freshInitialize' ? !supported : supported) ? 'ahp-session' : 'codex', + }); + }); + } + } + test('observes only the first post-readiness session request per recovery without issuing extra requests', () => runWithFakedTimers({}, async () => { const { client, transports } = createFactoryClient(undefined, undefined, undefined, undefined, { hasHighLoad: () => false }); const diagnostics: IConnectionDiagnosticEvent[] = []; @@ -3506,6 +3886,39 @@ suite('AgentHostProtocolClient', () => { assert.deepStrictEqual({ preparations, activeSessions }, { preparations: 2, activeSessions: 3 }); })); + for (const code of [JsonRpcErrorCodes.InternalError, JsonRpcErrorCodes.InvalidParams]) { + test(`initial authentication ${code} distinguishes a retryable host fault from rejected credentials`, () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { client, transports } = createFactoryClient(undefined, undefined, undefined, undefined, { hasHighLoad: () => false }, undefined, { + resolveInitialAuthentication: async () => ({ resource: 'https://api.example.com', token: 'fresh-credential' }), + }); + const connecting = assert.rejects(client.connect(), /identity validation failed/); + transports[0].connectDeferred.complete(); + const initialize = await waitForRequestAtWithin(transports[0], 'initialize', 0); + transports[0].fireMessage({ + jsonrpc: '2.0', id: initialize.id, + result: { protocolVersion: PROTOCOL_VERSION, serverSeq: 1, snapshots: [] } + }); + const firstAuthentication = await waitForRequestAtWithin(transports[0], 'authenticate', 0); + transports[0].fireMessage({ jsonrpc: '2.0', id: firstAuthentication.id, error: { code, message: 'identity validation failed' } }); + await connecting; + const afterFailure = client.connectionState; + if (code === JsonRpcErrorCodes.InternalError) { + const retry = await waitForTransport(transports, 1); + retry.connectDeferred.complete(); + const reconnect = await waitForRequestAtWithin(retry, 'reconnect', 0, 100); + retry.fireMessage({ jsonrpc: '2.0', id: reconnect.id, result: { type: ReconnectResultType.Replay, actions: [], missing: [] } }); + const authenticate = await waitForRequestAtWithin(retry, 'authenticate', 0, 100); + retry.fireMessage({ jsonrpc: '2.0', id: authenticate.id, result: { authenticated: true } }); + await waitForConnectedWithin(client, 100); + } + const final = client.connectionState; + client.dispose(); + assert.deepStrictEqual({ afterFailure, final }, code === JsonRpcErrorCodes.InternalError + ? { afterFailure: AgentHostClientState.Reconnecting, final: AgentHostClientState.Connected } + : { afterFailure: AgentHostClientState.Incompatible, final: AgentHostClientState.Incompatible }); + })); + } + for (const outcome of ['success', 'transient failure', 'disposed'] as const) { test(`prepares initial authentication before sending credentials: ${outcome}`, () => runWithFakedTimers({ useFakeTimers: true }, async () => { const preparing = new DeferredPromise(); @@ -3962,7 +4375,7 @@ suite('AgentHostProtocolClient', () => { clientId: client.clientId, lastSeenServerSeq: 5, subscriptions: [ROOT_STATE_URI], - _meta: { 'vscode.telemetryLevel': 'off' }, + _meta: { 'vscode.telemetryLevel': 'off', [AgentHostSessionUrisCapabilityMetaKey]: true }, }); transport.fireMessage({ jsonrpc: '2.0', id: request.id, @@ -4052,6 +4465,7 @@ suite('AgentHostProtocolClient', () => { reconnectTransport.connectDeferred.complete(); const reconnect = await waitForRequest(reconnectTransport, 'reconnect'); assert.deepStrictEqual((reconnect.params as { _meta?: Record })._meta, { + [AgentHostSessionUrisCapabilityMetaKey]: true, 'vscode.telemetryLevel': 'all', 'vscode.clientMachineId': 'client-machine-id', 'vscode.clientDevDeviceId': 'client-dev-device-id', @@ -4069,6 +4483,7 @@ suite('AgentHostProtocolClient', () => { }, { clientInfo: agentsWindowAgentHostClientInfo, meta: { + [AgentHostSessionUrisCapabilityMetaKey]: true, 'vscode.telemetryLevel': 'all', 'vscode.clientMachineId': 'client-machine-id', 'vscode.clientDevDeviceId': 'client-dev-device-id', diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts index 537b2844a4f78b..b523b09baebe0a 100644 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts @@ -155,6 +155,10 @@ class TestConnectionFactory extends Disposable implements IRemoteAgentHostConnec this._entries.set([...this._entries.get(), entry], undefined); } + withdrawEntry(entry: IRemoteAgentHostEntry): void { + this._entries.set(this._entries.get().filter(value => getEntryAddress(value) !== getEntryAddress(entry)), undefined); + } + getConnectionObserver(): RemoteAgentHostConnectionObserver { return state => this.observations.push({ state, time: Date.now() }); } @@ -1332,6 +1336,27 @@ suite('RemoteAgentHostService', () => { }); }); + test('withdrawing a permanently refused broker entry prevents the outer service from redialing cached credentials', () => runWithFakedTimers({ useFakeTimers: true, maxTaskCount: 10_000 }, async () => { + const factory = createFactory(); + const entry = cloudSandboxEntry('Missing brokered host', 'cloud:missing'); + const client = new MockProtocolClient(getEntryAddress(entry)); + factory.stage(entry, client); + service.reconnect(getEntryAddress(entry)); + const connected = service.waitForConnection(getEntryAddress(entry)); + await waitForFactoryConnection(factory, 1); + client.connectDeferred.complete(); + await connected; + factory.withdrawEntry(entry); + client.fireClose(AgentHostTransportFailureReason.HostNotRunning); + await timeout(60_000); + assert.deepStrictEqual({ + configured: service.configuredEntries.filter(value => getEntryAddress(value) === getEntryAddress(entry)).length, + creations: factory.createdConnectionCount, + connected: service.getConnection(getEntryAddress(entry)) !== undefined, + }, { configured: 0, creations: 1, connected: false }); + service.dispose(); + })); + test('falls back to a fresh dial when a retained entry has no client', async () => { const factory = createFactory(RemoteAgentHostEntryType.WSL); const entry: IRemoteAgentHostEntry = { diff --git a/src/vs/platform/agentHost/test/node/agentHostAutomationService.test.ts b/src/vs/platform/agentHost/test/node/agentHostAutomationService.test.ts index 94527f2c5d303c..b1a7b086908698 100644 --- a/src/vs/platform/agentHost/test/node/agentHostAutomationService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostAutomationService.test.ts @@ -103,6 +103,8 @@ suite('AgentHostAutomationService', () => { throw new Error('storage unavailable'); } }, + rename: async () => { }, + rm: async () => { }, }; storageService = disposables.add(new AgentHostStorageService( URI.file(`/agent-host-automation-service-${generateUuid()}.json`), diff --git a/src/vs/platform/agentHost/test/node/agentHostCatalogListReader.test.ts b/src/vs/platform/agentHost/test/node/agentHostCatalogListReader.test.ts index 71306684611d79..2e011535ba9691 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCatalogListReader.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCatalogListReader.test.ts @@ -6,6 +6,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { AgentSession } from '../../common/agent.js'; +import { CODEX_SESSION_MODEL_META_KEY } from '../../common/meta/codexSessionModel.js'; import { readSessionArtifacts, SESSION_META_ARTIFACTS_KEY } from '../../common/sessionArtifacts.js'; import { ChatInteractivity } from '../../common/state/protocol/state.js'; import { buildChatUri, buildSubagentChatUri, isSessionStatusArchived, isSessionStatusRead, readSessionCreationReference, readSessionEhcliAdoptable, readSessionExternal, readSessionFolderPickerDecision, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, SESSION_META_CREATED_BY_SESSION_KEY, SESSION_META_EHCLI_ADOPTABLE_KEY, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_GIT_KEY, SESSION_META_GITHUB_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, SESSION_META_WORKSPACELESS_KEY } from '../../common/state/sessionState.js'; @@ -68,6 +69,7 @@ suite('AgentHostCatalogListReader', () => { workingDirectories: ['file:///workspace', 'file:///other'], changes: { additions: 4, deletions: 2, files: 3 }, _meta: { + [CODEX_SESSION_MODEL_META_KEY]: { id: '@provider=openai:gpt-5.6-sol' }, [SESSION_META_MULTI_ROOT_KEY]: { workspaceFile: 'file:///workspace/project.code-workspace' }, [SESSION_META_FOLDER_PICKER_KEY]: { hidden: true, primary: 'file:///workspace' }, [SESSION_META_GITHUB_KEY]: { owner: 'microsoft', repo: 'vscode', pullRequestUrls: ['https://github.com/microsoft/vscode/pull/1'] }, @@ -149,6 +151,7 @@ suite('AgentHostCatalogListReader', () => { project: result.metadata.project && { uri: result.metadata.project.uri.toString(), displayName: result.metadata.project.displayName }, workingDirectories: result.metadata.workingDirectories?.map(directory => directory.toString()), changes: result.metadata.changes, + model: result.metadata.model, external: readSessionExternal(result.metadata._meta), workspaceless: readSessionWorkspaceless(result.metadata._meta), ehcliAdoptable: readSessionEhcliAdoptable(result.metadata._meta), @@ -172,6 +175,7 @@ suite('AgentHostCatalogListReader', () => { project: { uri: 'file:///workspace', displayName: 'Workspace' }, workingDirectories: ['file:///workspace', 'file:///other'], changes: data.changes, + model: { id: '@provider=openai:gpt-5.6-sol' }, external: true, workspaceless: true, ehcliAdoptable: true, diff --git a/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts b/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts index 81937b1155743e..52f37ea2ebf9e8 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCatalogSourceResolver.test.ts @@ -11,6 +11,7 @@ import { META_GIT_DATA_STATE, META_GIT_STATE, META_GITHUB_DATA_STATE, META_GITHU import { getWorkingDirectoryKey } from '../../common/agentHostWorkingDirectories.js'; import { AH_META_DEV_CONTAINER_WORKTREE_DB_KEY } from '../../common/meta/agentDevContainerWorktreeMeta.js'; import { readChatInputState, withChatInputState } from '../../common/meta/agentHostChatInputState.js'; +import { readCodexSessionModel, withCodexSessionModel } from '../../common/meta/codexSessionModel.js'; import { readRemoteSessionOrigin, REMOTE_SESSION_ORIGIN_METADATA_KEY, withRemoteSessionOrigin } from '../../common/meta/agentRemoteSessionMeta.js'; import { readSessionInitiator, SESSION_INITIATOR_METADATA_KEY, withSessionInitiator } from '../../common/meta/agentSessionInitiatorMeta.js'; import { parseSessionArtifacts, SessionArtifactType, SESSION_META_ARTIFACTS_KEY, withSessionArtifacts } from '../../common/sessionArtifacts.js'; @@ -18,7 +19,7 @@ import { ChatInteractivity, ChatOriginKind } from '../../common/state/protocol/s import { AH_META_CREATED_BY_SESSION_DB_KEY, AH_META_DEFAULT_CHAT_IS_READ_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, SESSION_META_CREATED_BY_SESSION_KEY, SESSION_META_EHCLI_ADOPTABLE_KEY, SESSION_META_EHCLI_ADOPTED_KEY, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_GIT_DATA_KEY, SESSION_META_GIT_KEY, SESSION_META_GITHUB_DATA_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, SESSION_META_WORKSPACELESS_KEY, SessionSourceControlOutcome, SessionStatus, withSessionCreationReference, withSessionEhcliAdoptable, withSessionFolderPickerDecision, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionWorkspaceless } from '../../common/state/sessionState.js'; import { AGENT_HOST_CATALOG_TITLE_LENGTH_LIMIT, encodeAgentHostCatalogPayload } from '../../node/agentHostCatalogProjection.js'; import { AgentHostCatalogSourceResolver, CHAT_BACKING_METADATA_KEY, ICatalogSourceState } from '../../node/agentHostCatalogSourceResolver.js'; -import { customChatTitleMetadataKey, customChatTitleSourceMetadataKey, SESSION_ARTIFACTS_KEY, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY } from '../../node/shared/persistSessionMetadata.js'; +import { customChatTitleMetadataKey, customChatTitleSourceMetadataKey, SESSION_ARTIFACTS_KEY, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY, SESSION_WORKING_DIRECTORIES_KEY } from '../../node/shared/persistSessionMetadata.js'; import { WORKTREE_META_REPOSITORY_ROOT } from '../../node/shared/worktreeIsolation.js'; const session = URI.parse('agenthost:catalog-source'); @@ -114,6 +115,22 @@ function createResolver(metadata: Readonly>, unpersistedB suite('AgentHostCatalogSourceResolver', () => { ensureNoDisposablesAreLeakedInTestSuite(); + test('restores aggregate roots independently of the main chat and honors exclusive replacement', async () => { + const aggregate = ['file:///original', 'file:///replacement']; + const warm = await createResolver({}).buildCatalogSyncRequest(session, { ...sourceState(), workingDirectories: aggregate }, { + [SESSION_WORKING_DIRECTORIES_KEY]: JSON.stringify(aggregate), + }, false); + const cold = await createResolver(warm.legacyMetadata).buildCatalogSyncRequest(session, { ...sourceState(), workingDirectories: ['file:///replacement'] }, {}, true); + const replacement = await createResolver(warm.legacyMetadata).buildCatalogSyncRequest(session, { ...sourceState(), workingDirectories: ['file:///replacement'] }, {}, false); + const reopened = await createResolver(replacement.legacyMetadata).buildCatalogSyncRequest(session, { ...sourceState(), workingDirectories: aggregate }, {}, true); + const legacy = await createResolver({}).buildCatalogSyncRequest(session, sourceState(), {}, true); + assert.deepStrictEqual({ + cold: cold.data.workingDirectories, + reopened: reopened.data.workingDirectories, + legacy: legacy.data.workingDirectories, + }, { cold: aggregate, reopened: ['file:///replacement'], legacy: ['file:///live'] }); + }); + test('restores artifacts for multiple chats from the shared session metadata key', async () => { const peer = 'agenthost-chat:catalog-source/peer'; const peerArtifact = { ...persistedArtifact, id: 'peer-artifact', chat: peer }; @@ -271,6 +288,23 @@ suite('AgentHostCatalogSourceResolver', () => { }); }); + test('projects the provider-qualified Codex model into the cached catalog metadata', async () => { + const state = sourceState(); + const result = await createResolver({}).buildCatalogSyncRequest(session, { + ...state, + meta: withCodexSessionModel(state.meta, { id: '@provider=openai:gpt-5.6-sol' }), + }, {}, false); + const encoded = encodeAgentHostCatalogPayload(result.data); + + assert.deepStrictEqual({ + model: readCodexSessionModel(result.data), + encoded: encoded.ok, + }, { + model: { id: '@provider=openai:gpt-5.6-sol' }, + encoded: true, + }); + }); + test('consumes the provided database reference and propagates metadata read failures', async () => { const absent = new AgentHostCatalogSourceResolver({ isUnpersistedChatBacking: () => false, diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts index 3d922fc225989a..43f8adfd73c1c9 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts @@ -1328,6 +1328,7 @@ class TestGitStateService extends Disposable implements IAgentHostGitStateServic this._onDidRefreshSessionGitState.fire(sessionKey); } getMaterializedWorktreeMeta(_sessionKey: string, _branchName: string): undefined { return undefined; } + async setFolderGitState(): Promise { } async resolveSessionBaseBranchName(): Promise { return undefined; } async setSessionGitHubState(_sessionKey: string, _state: ISessionGitHubState): Promise { } async recordSessionMerge(_sessionKey: string, _commit?: string): Promise { } diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts index e38b373a7873d9..8e764f46fb84e5 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts @@ -103,6 +103,7 @@ class TestGitStateService implements IAgentHostGitStateService { this.refreshes.push(sessionKey); } getMaterializedWorktreeMeta(_sessionKey: string, _branchName: string): undefined { return undefined; } + async setFolderGitState(): Promise { } async resolveSessionBaseBranchName(): Promise { return undefined; } async getSessionGitHubState(_sessionKey: string): Promise { diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts index e87e4e81fa5022..c9c9ca102a710b 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts @@ -47,6 +47,7 @@ const TEST_GIT_STATE_SERVICE: IAgentHostGitStateService = { refreshSessionGitState: async () => { }, getSessionGitState: session => testGitStates.get(session), getMaterializedWorktreeMeta: () => undefined, + setFolderGitState: async () => { }, resolveSessionBaseBranchName: async session => testGitStates.get(session)?.baseBranchName, setSessionGitHubState: async () => { }, recordSessionMerge: async () => { }, diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetStrategy.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetStrategy.test.ts index e7aa9df908d195..107058365f2c10 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetStrategy.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetStrategy.test.ts @@ -14,6 +14,7 @@ import { NullLogService } from '../../../log/common/log.js'; import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; import { AgentSession } from '../../common/agent.js'; import { NULL_CHECKPOINT_SERVICE, type IAgentHostCheckpointService } from '../../common/agentHostCheckpointService.js'; +import { META_DIFF_BASE_BRANCH } from '../../common/agentHostGitService.js'; import { type IAgentHostChangesetOperationService } from '../../common/agentHostChangesetOperationService.js'; import { type ChangesetDiffStrategy } from '../../common/agentHostChangesetService.js'; import { type IAgentHostChangesetSubscriptionService } from '../../common/agentHostChangesetSubscriptionService.js'; @@ -23,7 +24,7 @@ import { getWorkingDirectoryScopeId } from '../../common/agentHostWorkingDirecto import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { buildSessionDbUri } from '../../common/sessionDbUri.js'; import { ActionType } from '../../common/state/sessionActions.js'; -import { buildChatUri, buildDefaultChatUri, ChangesetStatus, FileEditKind, MessageKind, SessionStatus, type ISessionFileDiff } from '../../common/state/sessionState.js'; +import { buildChatUri, buildDefaultChatUri, ChangesetStatus, FileEditKind, MessageKind, SessionStatus, withSessionGitState, type ISessionFileDiff, type ISessionGitState } from '../../common/state/sessionState.js'; import { AgentConfigurationService } from '../../node/agentConfigurationService.js'; import { AgentHostChangesetService } from '../../node/agentHostChangesetService.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; @@ -95,6 +96,7 @@ suite('AgentHostChangesetStrategy', () => { const repositoryCalls: string[] = []; const checkpointCalls: string[] = []; const databaseCalls: string[] = []; + const gitStates = new Map(); const results: { git: readonly ISessionFileDiff[] | undefined; pair: { parent: string; current: string } | undefined; @@ -161,8 +163,9 @@ suite('AgentHostChangesetStrategy', () => { onDidRefreshSessionGitState: Event.None, onDidChangeSessionGitHubState: Event.None, refreshSessionGitState: async () => { }, - getSessionGitState: () => undefined, + getSessionGitState: resource => gitStates.get(resource), getMaterializedWorktreeMeta: () => undefined, + setFolderGitState: async () => { }, resolveSessionBaseBranchName: async () => undefined, setSessionGitHubState: async () => { }, recordSessionMerge: async () => { }, @@ -186,7 +189,7 @@ suite('AgentHostChangesetStrategy', () => { state.addChat(session, options.peer.resource, { workingDirectories: options.peer.workingDirectories }); addTurn(state, options.peer.turnId, options.peer.resource); } - return { service, state, db, diff, git, checkpoints, results, subscriptions, gitCalls, repositoryCalls, checkpointCalls, databaseCalls }; + return { service, state, db, diff, git, gitStates, checkpoints, results, subscriptions, gitCalls, repositoryCalls, checkpointCalls, databaseCalls }; } function nextPublication(state: AgentHostStateManager, uri: string): Promise { @@ -905,6 +908,112 @@ suite('AgentHostChangesetStrategy', () => { }, { session: ready([trackedDiff()]), turn: ready([trackedDiff()]), gitCalls: 2, reads: [1, 1] }); }); + test('main branch changes retain the persisted baseline while using the session folder', async () => { + const fixture = createFixture(); + await fixture.db.setMetadata(META_DIFF_BASE_BRANCH, 'release-A'); + fixture.gitStates.set(buildDefaultChatUri(session), { baseBranchName: 'main' }); + const calls: (string | undefined)[] = []; + fixture.git.computeSessionFileDiffs = async (_directory, options) => { + calls.push(options?.baseBranch); + return []; + }; + const branch = buildBranchChangesetUri(buildFolderChangesetOwnerUri(session, getWorkingDirectoryScopeId(['file:///repo']))); + const published = nextPublication(fixture.state, branch); + fixture.service.refreshBranchChangeset(session); + await published; + assert.deepStrictEqual(calls, ['release-A']); + }); + + test('single-chat folder replacement uses the destination baseline after clearing worktree metadata', async () => { + const fixture = createFixture(); + const destination = 'file:///destination'; + const main = buildDefaultChatUri(session); + await fixture.db.setMetadata(META_DIFF_BASE_BRANCH, ''); + fixture.state.dispatchServerAction(session, { type: ActionType.SessionWorkingDirectoryReplaced, directory: 'file:///repo', replacement: destination }); + fixture.state.dispatchServerAction(main, { type: ActionType.ChatWorkingDirectorySet, directory: destination }); + fixture.gitStates.set(main, { baseBranchName: 'main' }); + const calls: { directory: string; baseBranch: string | undefined }[] = []; + fixture.git.computeSessionFileDiffs = async (directory, options) => { + calls.push({ directory: directory.toString(), baseBranch: options?.baseBranch }); + return []; + }; + const branch = buildBranchChangesetUri(buildFolderChangesetOwnerUri(session, getWorkingDirectoryScopeId([destination]))); + const published = nextPublication(fixture.state, branch); + fixture.service.refreshBranchChangeset(main); + await published; + assert.deepStrictEqual(calls, [{ directory: destination, baseBranch: 'main' }]); + }); + + test('moved peer branch changes use the destination baseline after clearing inherited fork metadata', async () => { + const original = 'file:///repo'; + const destination = 'file:///destination'; + const main = buildDefaultChatUri(session); + const peer = buildChatUri(session, 'peer'); + const peerDb = new TestSessionDatabase(); + const fixture = createFixture({ + workingDirectories: [original, destination], + peer: { resource: peer, db: peerDb, turnId: 'peer-turn', workingDirectories: [destination] }, + }); + fixture.state.dispatchServerAction(main, { type: ActionType.ChatWorkingDirectorySet, directory: original }); + fixture.state.dispatchServerAction(session, { type: ActionType.SessionChatUpdated, chat: main, changes: { workingDirectories: [original] } }); + await fixture.db.setMetadata(META_DIFF_BASE_BRANCH, 'release-A'); + await peerDb.setMetadata(META_DIFF_BASE_BRANCH, ''); + fixture.gitStates.set(peer, { baseBranchName: 'main' }); + const calls: { directory: string; baseBranch: string | undefined }[] = []; + fixture.git.computeSessionFileDiffs = async (directory, options) => { + calls.push({ directory: directory.toString(), baseBranch: options?.baseBranch }); + return []; + }; + for (const [chat, directory] of [[main, original], [peer, destination]]) { + const branch = buildBranchChangesetUri(buildFolderChangesetOwnerUri(session, getWorkingDirectoryScopeId([directory]))); + const published = nextPublication(fixture.state, branch); + fixture.service.refreshBranchChangeset(chat); + await published; + } + assert.deepStrictEqual(calls, [ + { directory: original, baseBranch: 'release-A' }, + { directory: destination, baseBranch: 'main' }, + ]); + }); + + for (const destinationBase of ['main', undefined]) { + test(`relocated main branch changes ignore the session baseline with destination base ${destinationBase}`, async () => { + const original = 'file:///repo'; + const destination = 'file:///destination'; + const main = buildDefaultChatUri(session); + const peer = buildChatUri(session, 'peer'); + const peerDb = new TestSessionDatabase(); + const fixture = createFixture({ + workingDirectories: [original, destination], + peer: { resource: peer, db: peerDb, turnId: 'peer-turn', workingDirectories: [original] }, + }); + await fixture.db.setMetadata(META_DIFF_BASE_BRANCH, 'release-A'); + await peerDb.setMetadata(META_DIFF_BASE_BRANCH, 'release-A'); + fixture.state.setSessionMeta(session, withSessionGitState(undefined, { baseBranchName: 'release-A' })); + fixture.state.dispatchServerAction(main, { type: ActionType.ChatWorkingDirectorySet, directory: destination }); + fixture.state.dispatchServerAction(session, { type: ActionType.SessionChatUpdated, chat: main, changes: { workingDirectories: [destination] } }); + if (destinationBase) { + fixture.gitStates.set(main, { baseBranchName: destinationBase }); + } + fixture.gitStates.set(peer, { baseBranchName: 'release-A' }); + const calls: { directory: string; baseBranch: string | undefined }[] = []; + fixture.git.computeSessionFileDiffs = async (directory, options) => { + calls.push({ directory: directory.toString(), baseBranch: options?.baseBranch }); + return []; + }; + for (const [chat, directory] of [[main, destination], [peer, original]]) { + const branch = buildBranchChangesetUri(buildFolderChangesetOwnerUri(session, getWorkingDirectoryScopeId([directory]))); + const published = nextPublication(fixture.state, branch); + fixture.service.refreshBranchChangeset(chat); + await published; + } + assert.deepStrictEqual(calls, [ + { directory: destination, baseBranch: destinationBase }, + { directory: original, baseBranch: 'release-A' }, + ]); + }); + } + test('branch, uncommitted, and compare-turns computations remain Git-backed', async () => { const fixture = createFixture(); addEdit(fixture.db); diff --git a/src/vs/platform/agentHost/test/node/agentHostContributions.test.ts b/src/vs/platform/agentHost/test/node/agentHostContributions.test.ts index 4a1c83a0dcad48..0e8327df841ad2 100644 --- a/src/vs/platform/agentHost/test/node/agentHostContributions.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostContributions.test.ts @@ -48,6 +48,7 @@ const nullGitStateService: IAgentHostGitStateService = { onDidChangeSessionGitHubState: Event.None, async refreshSessionGitState() { }, getMaterializedWorktreeMeta() { return undefined; }, + async setFolderGitState() { }, async resolveSessionBaseBranchName() { return undefined; }, async setSessionGitHubState() { }, async recordSessionMerge() { }, diff --git a/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts b/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts index 5656d4fa61a721..d34de6e66deaa8 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts @@ -566,8 +566,10 @@ suite('AgentHostGitService', () => { resolveDiffBaseBranchName('origin/main', undefined), resolveDiffBaseBranchName('refs/remotes/origin/release', undefined), resolveDiffBaseBranchName(undefined, undefined), + resolveDiffBaseBranchName('', 'origin/main'), + resolveDiffBaseBranchName('', undefined), ], - ['persisted', 'gitState', 'persisted', 'main', 'release', undefined], + ['persisted', 'gitState', 'persisted', 'main', 'release', undefined, 'main', undefined], ); }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts b/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts index 02699c88ba6a94..392d032b8c2a18 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts @@ -29,6 +29,7 @@ import { buildChatUri, buildDefaultChatUri, getAllSessionRelatedPullRequestUrls, import { AgentConfigurationService } from '../../node/agentConfigurationService.js'; import type { IAgentHostAuthenticationService } from '../../node/agentHostAuthenticationService.js'; import { AgentHostGitStateService } from '../../node/agentHostGitStateService.js'; +import { resolveAgentMergeOwningChat, resolveGitHubStateFolder } from '../../node/agentHostBranchChangesetScope.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { createArtifactServerToolGroup } from '../../node/shared/artifactServerTools.js'; import { GitHubPullRequestLookup, GitHubPullRequestLookupOptions, GitHubRepositoryRef } from '../../../github/common/githubQueryService.js'; @@ -329,6 +330,67 @@ suite('AgentHostGitStateService', () => { } } + test('isolated main-chat Git state does not replace the aggregate original folder branch', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const h = createHarness(); + seedSession(h.stateManager, { workingDirectory: WORKING_DIRECTORY, gitState: { branchName: 'main' } }); + const worktree = 'file:///wd.worktrees/isolated'; + const main = buildDefaultChatUri(SESSION); + const peer = buildChatUri(SESSION, 'peer'); + h.stateManager.addChat(SESSION, peer, { workingDirectories: [WORKING_DIRECTORY] }); + await h.service.setFolderGitState(SESSION, [worktree], { branchName: 'isolated' }); + h.stateManager.dispatchServerAction(SESSION, { type: ActionType.SessionWorkingDirectorySet, directory: worktree }); + h.stateManager.dispatchServerAction(main, { type: ActionType.ChatWorkingDirectorySet, directory: worktree }); + h.setGitResult({ branchName: 'isolated', uncommittedChanges: 1 }); + await h.service.refreshSessionGitState(main, URI.parse(worktree)); + h.setGitResult({ branchName: 'main', uncommittedChanges: 2 }); + await h.service.refreshSessionGitState(SESSION, undefined); + assert.deepStrictEqual({ + aggregate: readSessionGitState(h.stateManager.getSessionSummary(SESSION)?._meta), + chat: h.service.getSessionGitState(main), + peer: h.service.getSessionGitState(peer), + queriedDirectories: h.gitCalls, + sessionFolder: resolveGitHubStateFolder(h.stateManager, SESSION).workingDirectory, + mainIsSessionFolder: resolveGitHubStateFolder(h.stateManager, main).isSessionFolder, + peerIsSessionFolder: resolveGitHubStateFolder(h.stateManager, peer).isSessionFolder, + mergeOwner: resolveAgentMergeOwningChat(h.stateManager, SESSION, getWorkingDirectoryKey(WORKING_DIRECTORY), undefined), + persistedAggregate: JSON.parse((await h.db.getMetadata(META_GIT_STATE))!), + persisted: JSON.parse((await h.db.getMetadata(META_GIT_DATA_STATE))!)[getWorkingDirectoryScopeId([worktree])], + }, { + aggregate: { branchName: 'main', uncommittedChanges: 2 }, + chat: { branchName: 'isolated', uncommittedChanges: 1 }, + peer: { branchName: 'main', uncommittedChanges: 2 }, + queriedDirectories: [worktree, WORKING_DIRECTORY], + sessionFolder: WORKING_DIRECTORY, + mainIsSessionFolder: false, + peerIsSessionFolder: true, + mergeOwner: peer, + persistedAggregate: { branchName: 'main', uncommittedChanges: 2 }, + persisted: { branchName: 'isolated', uncommittedChanges: 1 }, + }); + })); + + test('single-chat workspace replacement refreshes the worktree while retaining repository identity', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const h = createHarness(); + seedSession(h.stateManager, { workingDirectory: WORKING_DIRECTORY, project: WORKING_DIRECTORY, gitState: { branchName: 'main' } }); + const worktree = 'file:///wd.worktrees/isolated'; + const main = buildDefaultChatUri(SESSION); + h.stateManager.dispatchServerAction(SESSION, { type: ActionType.SessionWorkingDirectoryReplaced, directory: WORKING_DIRECTORY, replacement: worktree }); + h.stateManager.dispatchServerAction(main, { type: ActionType.ChatWorkingDirectorySet, directory: worktree }); + h.setGitResult({ branchName: 'isolated' }); + await h.service.refreshSessionGitState(SESSION, undefined); + await h.service.refreshSessionGitState(main, undefined); + assert.deepStrictEqual({ + project: h.stateManager.getSessionSummary(SESSION)?.project?.uri, + session: h.service.getSessionGitState(SESSION), + chat: h.service.getSessionGitState(main), + directories: h.gitCalls, + mainIsSessionFolder: resolveGitHubStateFolder(h.stateManager, main).isSessionFolder, + }, { + project: WORKING_DIRECTORY, session: { branchName: 'isolated' }, chat: { branchName: 'isolated' }, + directories: [worktree, worktree], mainIsSessionFolder: true, + }); + })); + test('seeds the materialized worktree branch while preserving known git state', () => { const h = createHarness(); seedSession(h.stateManager, { @@ -1210,6 +1272,58 @@ suite('AgentHostGitStateService', () => { assert.deepStrictEqual(h.gitBaseBranches, ['release']); })); + test('uses folder-scoped base branches after moving only the main chat to another workspace', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const h = createHarness(); + seedSession(h.stateManager, { + workingDirectory: WORKING_DIRECTORY, + project: 'file:///repo-a', + isolation: 'worktree', + baseBranch: 'release-A', + gitState: { branchName: 'agents/original', baseBranchName: 'release-A' }, + }); + const main = buildDefaultChatUri(SESSION); + const peer = buildChatUri(SESSION, 'peer'); + const replacement = 'file:///repo-b'; + h.stateManager.addChat(SESSION, peer, { workingDirectories: [WORKING_DIRECTORY] }); + h.stateManager.dispatchServerAction(SESSION, { type: ActionType.SessionWorkingDirectorySet, directory: replacement }); + h.stateManager.dispatchServerAction(main, { type: ActionType.ChatWorkingDirectorySet, directory: replacement }); + await h.service.setFolderGitState(SESSION, [replacement], { branchName: 'feature-b', baseBranchName: 'main' }); + await h.db.setMetadata(META_DIFF_BASE_BRANCH, 'origin/release-A'); + + const resolved = await Promise.all([main, peer, SESSION].map(key => h.service.resolveSessionBaseBranchName(key))); + await h.service.refreshSessionGitState(main, undefined); + await h.service.refreshSessionGitState(peer, undefined); + await h.service.refreshSessionGitState(SESSION, undefined); + + assert.deepStrictEqual({ + resolved, + directories: h.gitCalls, + baseBranches: h.gitBaseBranches, + }, { + resolved: ['main', 'release-A', 'release-A'], + directories: [replacement, WORKING_DIRECTORY, WORKING_DIRECTORY], + baseBranches: ['main', 'release-A', 'release-A'], + }); + })); + + test('uses the destination base after clearing a moved peer baseline without changing its sibling', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const h = createHarness(); + seedSession(h.stateManager, { + workingDirectory: WORKING_DIRECTORY, + project: 'file:///repo-a', + isolation: 'worktree', + baseBranch: 'release-A', + }); + const main = buildDefaultChatUri(SESSION); + const peer = buildChatUri(SESSION, 'peer'); + const replacement = 'file:///repo-b'; + h.stateManager.addChat(SESSION, peer, { workingDirectories: [replacement] }); + await h.service.setFolderGitState(SESSION, [replacement], { branchName: 'feature-b', baseBranchName: 'main' }); + await h.db.setMetadata(META_DIFF_BASE_BRANCH, ''); + + assert.deepStrictEqual(await Promise.all([peer, main].map(key => h.service.resolveSessionBaseBranchName(key))), ['main', 'release-A']); + })); + test('uses the persisted base branch when the selected branch is checked out directly', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const h = createHarness(); seedSession(h.stateManager, { diff --git a/src/vs/platform/agentHost/test/node/agentHostMergeOperationProvider.test.ts b/src/vs/platform/agentHost/test/node/agentHostMergeOperationProvider.test.ts index 53e52c058a65b0..5e3655bf7a7add 100644 --- a/src/vs/platform/agentHost/test/node/agentHostMergeOperationProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostMergeOperationProvider.test.ts @@ -31,6 +31,7 @@ const nullGitStateService = new class implements IAgentHostGitStateService { readonly onDidChangeSessionGitHubState = Event.None; async refreshSessionGitState(): Promise { } getMaterializedWorktreeMeta(): undefined { return undefined; } + async setFolderGitState(): Promise { } async resolveSessionBaseBranchName(): Promise { return 'main'; } async setSessionGitHubState(_sessionKey: string, _state: ISessionGitHubState): Promise { } async recordSessionMerge(): Promise { } diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts index 7a35232f61026c..91f3bc1f823d3b 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts @@ -37,6 +37,7 @@ const nullGitStateService = new class implements IAgentHostGitStateService { readonly onDidChangeSessionGitHubState = Event.None; async refreshSessionGitState(): Promise { } getMaterializedWorktreeMeta(): undefined { return undefined; } + async setFolderGitState(): Promise { } async resolveSessionBaseBranchName(): Promise { return undefined; } async getSessionGitHubState(): Promise { return undefined; } async setSessionGitHubState(): Promise { } @@ -249,6 +250,7 @@ suite('AgentHostPullRequestOperationContribution', () => { onDidChangeSessionGitHubState: Event.None, refreshSessionGitState: async () => { }, getMaterializedWorktreeMeta: () => undefined, + setFolderGitState: async () => { }, resolveSessionBaseBranchName: async () => undefined, setSessionGitHubState: async (key, state) => { recordedGitHubStates.push([key, state]); }, recordSessionMerge: async () => { }, diff --git a/src/vs/platform/agentHost/test/node/agentHostReviewService.integrationTest.ts b/src/vs/platform/agentHost/test/node/agentHostReviewService.integrationTest.ts index 3d437eb7a84773..f71b1dff69112e 100644 --- a/src/vs/platform/agentHost/test/node/agentHostReviewService.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/agentHostReviewService.integrationTest.ts @@ -67,6 +67,7 @@ suite.skip('AgentHostReviewService (real git)', () => { refreshSessionGitState: async () => { }, getSessionGitState: () => undefined, getMaterializedWorktreeMeta: () => undefined, + setFolderGitState: async () => { }, resolveSessionBaseBranchName: async () => undefined, setSessionGitHubState: async () => { }, recordSessionMerge: async () => { }, diff --git a/src/vs/platform/agentHost/test/node/agentHostReviewService.test.ts b/src/vs/platform/agentHost/test/node/agentHostReviewService.test.ts index b6363223350cbd..908e35e432882a 100644 --- a/src/vs/platform/agentHost/test/node/agentHostReviewService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostReviewService.test.ts @@ -26,6 +26,7 @@ function createNoopGitStateService(): IAgentHostGitStateService { refreshSessionGitState: async () => { }, getSessionGitState: () => undefined, getMaterializedWorktreeMeta: () => undefined, + setFolderGitState: async () => { }, resolveSessionBaseBranchName: async () => undefined, setSessionGitHubState: async () => { }, recordSessionMerge: async () => { }, @@ -77,6 +78,7 @@ suite('AgentHostReviewService', () => { refreshSessionGitState: async () => { }, getSessionGitState: owner => owner === chat ? chatGitState : undefined, getMaterializedWorktreeMeta: () => undefined, + setFolderGitState: async () => { }, resolveSessionBaseBranchName: async () => undefined, setSessionGitHubState: async () => { }, recordSessionMerge: async () => { }, diff --git a/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts b/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts index f336391f91ed43..3be832bc17d43b 100644 --- a/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts @@ -319,6 +319,15 @@ suite('AgentHostSessionTitleController', () => { }); } + test('defaults new sessions to utility naming without a configured strategy', () => { + const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + const controller = disposables.add(new AgentHostSessionTitleController(stateManager, { + sessionDataService: createSessionDataService(), + }, new NullLogService())); + + assert.strictEqual(controller.getAutomaticTitleGenerationStrategy(), 'utility'); + }); + test('clears an unregistered strategy snapshot after failed creation', () => { const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); const session = URI.parse('agenthost-session://copilot/creating'); diff --git a/src/vs/platform/agentHost/test/node/agentHostShutdown.test.ts b/src/vs/platform/agentHost/test/node/agentHostShutdown.test.ts index 772a52675f5f7c..3bbfd7715fa9a2 100644 --- a/src/vs/platform/agentHost/test/node/agentHostShutdown.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostShutdown.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { DeferredPromise, timeout } from '../../../../base/common/async.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; import { flushAgentHostPersistenceBeforeShutdown, shutdownAgentHostBeforeDispose } from '../../node/agentHostShutdown.js'; @@ -19,6 +20,36 @@ suite('AgentHostShutdown', () => { )); }); + test('a failed persistence flush still waits for the other writes', async () => { + const pending = new DeferredPromise(); + const errors: unknown[] = []; + let flushed = false; + const flush = flushAgentHostPersistenceBeforeShutdown( + [Promise.reject(new Error('storage unavailable')), pending.p], + 3000, + { error: (_message, error) => errors.push(error), warn: () => { } }, + ).then(() => { flushed = true; }); + try { + await timeout(0); + const flushedBeforeWrite = flushed; + const errorsBeforeWrite = errors.length; + pending.complete(); + await flush; + assert.deepStrictEqual({ + flushedBeforeWrite, + errorsBeforeWrite, + errors: errors.map(error => error instanceof Error ? error.message : String(error)), + }, { + flushedBeforeWrite: false, + errorsBeforeWrite: 1, + errors: ['storage unavailable'], + }); + } finally { + pending.complete(); + await flush; + } + }); + test('providers shut down before persistence is flushed', async () => { const steps: string[] = []; diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts index aa68c6c38da20e..d2072b412adf4b 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts @@ -656,7 +656,7 @@ suite('AgentHostStateManager', () => { addedWorkingDirectories: added?.type === NotificationType.SessionAdded ? added.summary.workingDirectories : undefined, }, { status: SessionStatus.InProgress, - project: provisional.project, + project: persisted.project, workingDirectories: persisted.workingDirectories, addedStatus: SessionStatus.InProgress, addedProject: persisted.project, @@ -664,6 +664,25 @@ suite('AgentHostStateManager', () => { }); }); + test('project changes keep catalogue and session snapshots consistent', () => { + manager.createSession(makeSessionSummary()); + const previous = manager.getSessionState(sessionUri); + const project = { uri: 'file:///new-project', displayName: 'New Project' }; + manager.setSessionProject(sessionUri, project); + const snapshot = manager.getSnapshot(sessionUri)?.state as SessionState; + assert.deepStrictEqual({ + previous: previous?.project, + state: manager.getSessionState(sessionUri)?.project, + snapshot: snapshot.project, + summary: manager.getSessionSummary(sessionUri)?.project, + }, { + previous: makeSessionSummary().project, + state: project, + snapshot: project, + summary: project, + }); + }); + test('getActiveTurnId returns active turn id after turnStarted', () => { manager.createSession(makeSessionSummary()); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); diff --git a/src/vs/platform/agentHost/test/node/agentHostStorageService.test.ts b/src/vs/platform/agentHost/test/node/agentHostStorageService.test.ts index 365a22314d8741..b25b4b79979bdc 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStorageService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStorageService.test.ts @@ -4,9 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { readFile, unlink, writeFile } from 'fs/promises'; +import { mkdir, readFile, readdir, rename, rm, unlink, writeFile } from 'fs/promises'; import { tmpdir } from 'os'; +import { DeferredPromise } from '../../../../base/common/async.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { join } from '../../../../base/common/path.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { getRandomTestPath } from '../../../../base/test/node/testUtils.js'; @@ -25,6 +27,8 @@ suite('AgentHostStorageService', () => { const writer: IAgentHostStorageWriter = { mkdir: async () => { }, writeFile: async (_path, contents) => { writes.push(contents); }, + rename: async () => { }, + rm: async () => { }, }; const service = disposables.add(new AgentHostStorageService( URI.file('/agent-host-storage-service-test.json'), @@ -61,6 +65,8 @@ suite('AgentHostStorageService', () => { throw new Error('disk is unavailable'); } }, + rename: async () => { }, + rm: async () => { }, }; const service = disposables.add(new AgentHostStorageService( URI.file('/agent-host-storage-service-test.json'), @@ -106,6 +112,61 @@ suite('AgentHostStorageService', () => { } }); + for (const fail of [false, true]) { + test(`a ${fail ? 'failed' : 'pending'} write preserves the previous complete storage file`, async () => { + const directory = getRandomTestPath(tmpdir()); + await mkdir(directory); + const path = join(directory, 'storage.json'); + await writeFile(path, '{"previous":true}', 'utf8'); + const writeStarted = new DeferredPromise(); + const releaseWrite = new DeferredPromise(); + const writer: IAgentHostStorageWriter = { + mkdir: async () => { }, + writeFile: async (target, contents) => { + await writeFile(target, '{', 'utf8'); + writeStarted.complete(); + await releaseWrite.p; + if (fail) { + throw new Error('interrupted write'); + } + await writeFile(target, contents, 'utf8'); + }, + rename, + rm: path => rm(path, { force: true }), + }; + const service = disposables.add(new AgentHostStorageService(URI.file(path), new NullLogService(), writer)); + try { + service.set('next', true); + await writeStarted.p; + const before = await readFile(path, 'utf8'); + const reopened = disposables.add(new AgentHostStorageService(URI.file(path), new NullLogService())); + releaseWrite.complete(); + if (fail) { + await assert.rejects(service.whenIdle(), /interrupted write/); + } else { + await service.whenIdle(); + } + assert.deepStrictEqual({ + before, + reopenedLoadError: reopened.loadError, + reopenedPrevious: reopened.get('previous'), + after: await readFile(path, 'utf8'), + files: await readdir(directory), + }, { + before: '{"previous":true}', + reopenedLoadError: undefined, + reopenedPrevious: true, + after: fail ? '{"previous":true}' : '{"previous":true,"next":true}', + files: ['storage.json'], + }); + } finally { + releaseWrite.complete(); + await Promise.allSettled([service.whenIdle()]); + await rm(directory, { recursive: true, force: true }); + } + }); + } + test('a rejected flushed value cannot be resurrected by a later unrelated write', async () => { let fail = true; const writes: string[] = []; @@ -118,6 +179,8 @@ suite('AgentHostStorageService', () => { } writes.push(contents); }, + rename: async () => { }, + rm: async () => { }, }; const service = disposables.add(new AgentHostStorageService( URI.file('/agent-host-storage-service-test.json'), diff --git a/src/vs/platform/agentHost/test/node/agentHostTelemetryReporter.test.ts b/src/vs/platform/agentHost/test/node/agentHostTelemetryReporter.test.ts index b87f526eb111bc..0ed091328ba24c 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTelemetryReporter.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTelemetryReporter.test.ts @@ -113,6 +113,10 @@ suite('AgentHostTelemetryReporter', () => { assert.deepStrictEqual(service.standardEvents.splice(0), baseline.map(event => ({ ...event, data: { ...event.data, ...(provider === 'codex' ? snapshot : {}) }, }))); + // A legacy/selected model ID cannot determine the dispatch route. The + // provider-owned snapshot wins, and only Codex reports this field. + reporter.turnCompleted({ ...completion, model: 'legacy-model', codexModelProvider: 'openai' }); + assert.strictEqual(service.standardEvents.splice(0)[0].data?.codexModelProvider, provider === 'codex' ? 'openai' : undefined); } }); diff --git a/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts index 13f19011e990b4..134dd9621f9cf3 100644 --- a/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts @@ -297,11 +297,16 @@ suite('AgentSideEffects — tool call telemetry', () => { }], [ISessionWorkspaceConversionService, { _serviceBrand: undefined, - requestSessionWorkspaceUpdate: () => { }, + supportsChatIsolation: () => false, + canIsolateChat: () => false, + requestChatIsolation: () => { }, + restoreChatIsolation: async () => { }, + requestSessionWorkspaceUpdate: () => true, isPending: () => false, + isConversionTurn: () => false, cancel: () => { }, updateSessionWorkspace: async () => { }, - }], + } satisfies ISessionWorkspaceConversionService], ); const instantiationService = disposables.add(new InstantiationService(services, /*strict*/ true)); const chatContributions = disposables.add(new AgentHostChatContributions(logService, instantiationService)); diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts index 600b91168efb2b..c12368ece9a2b2 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts @@ -254,11 +254,16 @@ suite('AgentSideEffects — turn hang telemetry', () => { }], [ISessionWorkspaceConversionService, { _serviceBrand: undefined, - requestSessionWorkspaceUpdate: () => { }, + supportsChatIsolation: () => false, + canIsolateChat: () => false, + requestChatIsolation: () => { }, + restoreChatIsolation: async () => { }, + requestSessionWorkspaceUpdate: () => true, isPending: () => false, + isConversionTurn: () => false, cancel: () => { }, updateSessionWorkspace: async () => { }, - }], + } satisfies ISessionWorkspaceConversionService], ); const instantiationService = disposables.add(new InstantiationService(services, /*strict*/ true)); const chatContributions = disposables.add(new AgentHostChatContributions(logService, instantiationService)); diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts index ce455cadb45ca7..fb3f2750b56c25 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts @@ -21,9 +21,10 @@ import { ITelemetryData, ITelemetryService, TelemetryLevel } from '../../../tele import { TelemetryTrustedValue } from '../../../telemetry/common/telemetryUtils.js'; import { createAgentModelByokMeta } from '../../common/agentModelByokMeta.js'; import { getTelemetryChatSessionId } from '../../common/agentTelemetryCorrelation.js'; -import { AgentSession, IAgent, type AgentModelCallFinishedOutcome, type IAgentTurnTokenUsage } from '../../common/agent.js'; +import { AgentSession, COPILOT_CLI_AGENT_PROVIDER_ID, IAgent, type AgentModelCallFinishedOutcome, type IAgentTurnTokenUsage } from '../../common/agent.js'; import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind, type IAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; +import { COPILOT_HYDRA_FUSION_MODEL_ID } from '../../common/copilotCliConfig.js'; import { getCodexAccountTelemetryContext } from '../../node/codex/codexAccountTelemetry.js'; import type { ICodexAccountState } from '../../node/codex/codexAccountState.js'; import type { SessionMode } from '../../common/agentHostSchema.js'; @@ -291,11 +292,16 @@ suite('AgentSideEffects — turn tracker telemetry', () => { }], [ISessionWorkspaceConversionService, { _serviceBrand: undefined, - requestSessionWorkspaceUpdate: () => { }, + supportsChatIsolation: () => false, + canIsolateChat: () => false, + requestChatIsolation: () => { }, + restoreChatIsolation: async () => { }, + requestSessionWorkspaceUpdate: () => true, isPending: () => false, + isConversionTurn: () => false, cancel: () => { }, updateSessionWorkspace: async () => { }, - }], + } satisfies ISessionWorkspaceConversionService], ); const instantiationService = disposables.add(new InstantiationService(services, /*strict*/ true)); chatContributions = disposables.add(new AgentHostChatContributions(logService, instantiationService)); @@ -368,9 +374,9 @@ suite('AgentSideEffects — turn tracker telemetry', () => { fire({ type: ActionType.ChatTurnComplete, turnId: 'shared-turn', duration: 1 }, chat); } const expected = [ - { chatgptAccountState: 'signedIn', chatgptPlanTier: 'plus', chatgptWeeklyQuotaState: 'available', chatgptWeeklyUsedPercentBucket: 40 }, - { chatgptAccountState: 'signedIn', chatgptPlanTier: 'free', chatgptWeeklyQuotaState: 'available', chatgptWeeklyUsedPercentBucket: 60 }, - { chatgptAccountState: 'signedOut', chatgptWeeklyQuotaState: 'unavailable' }, + { chatgptAccountState: 'signedIn', chatgptPlanTier: 'plus', chatgptWeeklyQuotaState: 'available', chatgptWeeklyUsedPercentBucket: 40, chatgptFiveHourQuotaState: 'missing' }, + { chatgptAccountState: 'signedIn', chatgptPlanTier: 'free', chatgptWeeklyQuotaState: 'available', chatgptWeeklyUsedPercentBucket: 60, chatgptFiveHourQuotaState: 'missing' }, + { chatgptAccountState: 'signedOut', chatgptWeeklyQuotaState: 'unavailable', chatgptFiveHourQuotaState: 'unavailable' }, ]; const sent = [defaultChatUri, peerChat, secondChat].map(chat => { const context = sends.getCalls().find(call => call.args[0].toString() === chat)?.args[7]; @@ -394,9 +400,11 @@ suite('AgentSideEffects — turn tracker telemetry', () => { codex.chatgptPlanTier = 'pro'; codex.chatgptWeeklyQuotaState = 'available'; codex.chatgptWeeklyUsedPercentBucket = 100; + codex.chatgptFiveHourQuotaState = 'available'; + codex.chatgptFiveHourUsedPercentBucket = 100; fire({ type: ActionType.ChatTurnComplete, turnId: 'turn', duration: 1 }); assert.deepStrictEqual(completedEvents().map(event => Object.fromEntries(Object.entries(event.data as object).filter(([key]) => key.startsWith('chatgpt')))), [ - { chatgptAccountState: 'signedIn', chatgptPlanTier: 'plus', chatgptWeeklyQuotaState: 'missing' }, + { chatgptAccountState: 'signedIn', chatgptPlanTier: 'plus', chatgptWeeklyQuotaState: 'missing', chatgptFiveHourQuotaState: 'missing' }, ]); }); @@ -1564,6 +1572,52 @@ suite('AgentSideEffects — turn tracker telemetry', () => { }); }); + test('uses the resolved usage model while preserving HydraFusion selection', () => { + sinon.stub(agent, 'id').value(COPILOT_CLI_AGENT_PROVIDER_ID); + setupSession(); + agent.setModels([ + { provider: 'mock', id: COPILOT_HYDRA_FUSION_MODEL_ID, name: 'HydraFusion', supportsVision: false }, + { provider: 'mock', id: 'gpt-5.5', name: 'GPT 5.5', supportsVision: false }, + ]); + startTurn('turn-explicit', 'hello', COPILOT_HYDRA_FUSION_MODEL_ID); + fire({ type: ActionType.ChatUsage, turnId: 'turn-explicit', usage: { model: 'gpt-5.5' } }); + fire({ type: ActionType.ChatTurnComplete, turnId: 'turn-explicit', duration: 1000 }); + + agent.chatModel = { id: COPILOT_HYDRA_FUSION_MODEL_ID }; + startTurn('turn-default'); + fire({ type: ActionType.ChatError, turnId: 'turn-default', duration: 1000, part: createErrorResponsePart({ errorType: 'query', message: 'failed before routing' }) }); + + assert.deepStrictEqual(completedEvents().map(event => { + const data = event.data as Record; + return { + model: capturedModel(data), + modelSelectionKind: data.modelSelectionKind, + result: data.result, + }; + }), [ + { model: { trusted: true, value: 'gpt-5.5' }, modelSelectionKind: 'hydrafusion', result: 'success' }, + { model: { trusted: true, value: COPILOT_HYDRA_FUSION_MODEL_ID }, modelSelectionKind: 'hydrafusion', result: 'error' }, + ]); + }); + + test('does not classify another provider model named HydraFusion as the routing mode', () => { + setupSession(); + agent.setModels([ + { provider: 'mock', id: COPILOT_HYDRA_FUSION_MODEL_ID, name: 'HydraFusion', supportsVision: false }, + ]); + startTurn('turn-explicit', 'hello', COPILOT_HYDRA_FUSION_MODEL_ID); + fire({ type: ActionType.ChatTurnComplete, turnId: 'turn-explicit', duration: 1000 }); + + const data = completedEvents()[0].data as Record; + assert.deepStrictEqual({ + model: capturedModel(data), + modelSelectionKind: data.modelSelectionKind, + }, { + model: { trusted: true, value: COPILOT_HYDRA_FUSION_MODEL_ID }, + modelSelectionKind: 'explicit', + }); + }); + test('uses the concrete provider default across turn outcomes while preserving Default selection', () => { setupSession(); agent.setModels([{ provider: 'mock', id: 'gpt-5.5', name: 'GPT 5.5', supportsVision: false }]); @@ -1590,7 +1644,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { ]); }); - test('does not treat an Auto provider default as the effective model', () => { + test('preserves inherited Auto selection without reporting it as the observed model', () => { setupSession(); agent.setModels([ { provider: 'mock', id: 'auto', name: 'Auto', supportsVision: false }, @@ -1607,7 +1661,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { modelSelectionKind: data.modelSelectionKind, }, { model: undefined, - modelSelectionKind: 'default', + modelSelectionKind: 'auto', }); }); @@ -2284,8 +2338,8 @@ suite('AgentSideEffects — turn tracker telemetry', () => { account = { usageSource: 'openai', status: 'error' }; fire({ type: ActionType.ChatTurnComplete, turnId: queuedTurnId, duration: 1 }); assert.deepStrictEqual(completedEvents().map(event => Object.fromEntries(Object.entries(event.data as object).filter(([key]) => key.startsWith('chatgpt')))), [ - { chatgptAccountState: 'signedOut', chatgptWeeklyQuotaState: 'unavailable' }, - { chatgptAccountState: 'signedIn', chatgptPlanTier: 'plus', chatgptWeeklyQuotaState: 'missing' }, + { chatgptAccountState: 'signedOut', chatgptWeeklyQuotaState: 'unavailable', chatgptFiveHourQuotaState: 'unavailable' }, + { chatgptAccountState: 'signedIn', chatgptPlanTier: 'plus', chatgptWeeklyQuotaState: 'missing', chatgptFiveHourQuotaState: 'missing' }, ]); }); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 9961f5d1cd22d3..d81fb72e6828fe 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -33,7 +33,7 @@ import { AgentHostStartupPerformance, type IAgentHostStartupPerformance, type IA import { AgentHostLaunchKind } from '../../common/agentHostTelemetry.js'; import { FileService } from '../../../files/common/fileService.js'; import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; -import { AgentChatMigrationDeferred, AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, SubagentChatSignal, resolveAgentChatContext, type IAgent, type IAgentChatAdoptionResult, type IAgentChatContext, type IAgentChatDataChange, type IAgentChatMetadata, type IAgentChatMetadataOptions, type IAgentChats, type IAgentCreateChatForkSource, type IAgentCreateChatOptions, type IAgentCreateChatResult, type IAgentCreateSessionConfig, type IAgentCreateSessionResult, type IAgentDescriptor, type IAgentDiscoveredChat, type IAgentLegacyChat, type IAgentMaterializeChatEvent, type IAgentSessionMetadata, type IAgentSpawnChatEvent } from '../../common/agent.js'; +import { AgentChatMigrationDeferred, AgentSession, AgentWorkingDirectoryChangedError, GITHUB_COPILOT_PROTECTED_RESOURCE, SubagentChatSignal, resolveAgentChatContext, type IAgent, type IAgentChatAdoptionResult, type IAgentChatContext, type IAgentChatDataChange, type IAgentChatMetadata, type IAgentChatMetadataOptions, type IAgentChats, type IAgentCreateChatForkSource, type IAgentCreateChatOptions, type IAgentCreateChatResult, type IAgentCreateSessionConfig, type IAgentCreateSessionResult, type IAgentDescriptor, type IAgentDiscoveredChat, type IAgentLegacyChat, type IAgentMaterializeChatEvent, type IAgentSessionMetadata, type IAgentSpawnChatEvent } from '../../common/agent.js'; import { IConnectionTrackerService } from '../../common/agentService.js'; import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; import { AgentHostAutoArchiveMergedSessionsAfterDaysConfigKey, AgentHostAutoDeleteArchivedMergedSessionsAfterDaysConfigKey, AgentHostArtifactToolsConfigKey, AgentHostAutoAttachPullRequestsConfigKey, AgentHostSessionCatalogEnabledConfigKey, AgentHostExternalSessionsMode, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostShowExternalSessionsConfigKey } from '../../common/agentHostSchema.js'; @@ -45,7 +45,7 @@ import { CodexSessionConfigKey } from '../../common/codexSessionConfigKeys.js'; import { ISessionCatalogSyncPendingSnapshot, ISessionCatalogSyncSnapshot, ISessionDatabase, ISessionDataService, SessionCatalogSyncWriteResult } from '../../common/sessionDataService.js'; import { IAgentHostGitStateService, META_GITHUB_DATA_STATE, META_GITHUB_STATE, META_SOURCE_CONTROL_STATE } from '../../common/agentHostGitStateService.js'; import { getChatChangesSummaryMetadataKey, META_CHANGES_SUMMARY, META_CHANGESET_BRANCH, META_CHANGESET_SESSION } from '../../common/agentHostChangesetService.js'; -import { GitRefType, type IAgentHostGitService } from '../../common/agentHostGitService.js'; +import { GitRefType, META_DIFF_BASE_BRANCH, type IAgentHostGitService } from '../../common/agentHostGitService.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { readSessionSandboxPolicy } from '../../common/meta/agentSandboxPolicyMeta.js'; import { getSessionSandboxConfig } from '../../node/sessionSandbox.js'; @@ -59,6 +59,7 @@ import { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../../co import { readAgentMessageDelegationMeta } from '../../common/meta/agentMessageDelegationMeta.js'; import { readRemoteSessionDepth, readRemoteSessionOrigin, REMOTE_SESSION_ORIGIN_METADATA_KEY, withRemoteSessionOrigin } from '../../common/meta/agentRemoteSessionMeta.js'; import { AH_META_DEV_CONTAINER_WORKTREE_DB_KEY } from '../../common/meta/agentDevContainerWorktreeMeta.js'; +import { readCodexSessionModel, withCodexSessionModel } from '../../common/meta/codexSessionModel.js'; import { AgentSystemNotificationWorkspaceKind, serializeAgentWorkspaceTransition } from '../../common/meta/agentSystemNotificationMeta.js'; import { IProductService } from '../../../product/common/productService.js'; import { AgentService } from '../../node/agentService.js'; @@ -70,8 +71,9 @@ import type { IAgentHostStorageService } from '../../node/agentHostStorageServic import { AGENT_HOST_CATALOG_VERIFICATION_VERSION_STORAGE_KEY, CATALOG_VERIFICATION_VERSION } from '../../node/agentHostCatalogReconciliationService.js'; import { AgentSessionRegistry, type IRegisteredSession } from '../../node/agentSessionRegistry.js'; import { AgentHostManagementService } from '../../node/agentHostManagementService.js'; -import { AGENT_HOST_TITLE_SOURCE_AGENT, customChatTitleMetadataKey, customChatTitleSourceMetadataKey, SESSION_ARTIFACTS_KEY, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY } from '../../node/shared/persistSessionMetadata.js'; +import { AGENT_HOST_TITLE_SOURCE_AGENT, customChatTitleMetadataKey, customChatTitleSourceMetadataKey, SESSION_ARTIFACTS_KEY, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY, SESSION_WORKING_DIRECTORIES_KEY } from '../../node/shared/persistSessionMetadata.js'; import { MockAgent, ScriptedMockAgent } from './mockAgent.js'; +import type { IAgentHostClientConnectionService } from '../../node/agentHostClientConnectionService.js'; import { readChatInputState } from '../../common/meta/agentHostChatInputState.js'; import { mapSessionEventsToHistoryRecords } from './historyRecordFixtures.js'; import { type ISessionEvent } from './copilotTestEvents.js'; @@ -1516,6 +1518,83 @@ suite('AgentService (node dispatcher)', () => { }); suite('catalog summary synchronization', () => { + test('a provisional Codex session seeds its provider-qualified model into live metadata', async () => { + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new MockAgent('codex')); + const model = { id: '@provider=openai:gpt-5.6-sol' }; + agent.chatModel = model; + const createChat = agent.chats.createChat; + agent.chats.createChat = async (chat, context, options) => ({ + ...(await createChat(chat, context, options) ?? {}), + provisional: true, + }); + registerTestAgentProvider(svc, agent); + + const session = await svc.createSession({ provider: 'codex', model }); + + assert.deepStrictEqual(readCodexSessionModel(getStateManager(svc).getSessionSummary(session.toString())), model); + }); + + test('live Codex catalog writes retain the current model through turn and read-state transitions', async () => { + const catalogDatabase = new TestAgentHostOrchestratorDatabase(); + const svc = disposables.add(createTestAgentService( + new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService(), + undefined, undefined, undefined, undefined, undefined, [], undefined, undefined, catalogDatabase, + )); + getConfigurationService(svc).updateRootConfig({ [AgentHostSessionCatalogEnabledConfigKey]: true }); + const agent = disposables.add(new MockAgent('codex')); + const initialModel = { id: '@provider=openai:gpt-5.6-sol' }; + const nextModel = { id: '@provider=openai:gpt-6-astra' }; + agent.chatModel = initialModel; + registerTestAgentProvider(svc, agent); + const session = await svc.createSession({ provider: 'codex', model: initialModel }); + const chat = buildDefaultChatUri(session); + const stateManager = getStateManager(svc); + const nextSummaryChange = () => Event.toPromise(Event.filter(stateManager.onDidChangeSessionSummary, event => event.session === session.toString())); + const snapshots: Array<{ stage: string; state: string | undefined; catalog: string | undefined; listing: string | undefined }> = []; + const capture = async (stage: string) => { + await svc.whenCatalogReconciliationIdle(); + const [listed] = await svc.listSessions(); + snapshots.push({ + stage, + state: readCodexSessionModel(stateManager.getSessionSummary(session.toString()))?.id, + catalog: readCodexSessionModel(catalogDataOf(await catalogDatabase.getSessionV2(session.toString())))?.id, + listing: listed.model?.id, + }); + }; + + await capture('created'); + agent.chatModel = nextModel; + const sent = Event.toPromise(agent.onDidSendMessage); + const turnStarted = nextSummaryChange(); + svc.dispatchAction(chat, { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', + message: { text: 'hello', origin: { kind: MessageKind.User }, model: nextModel }, + }, 'client-1', 1); + await Promise.all([sent, turnStarted]); + await capture('turnStarted'); + const readChanged = nextSummaryChange(); + svc.dispatchAction(session.toString(), { type: ActionType.SessionIsReadChanged, isRead: true }, 'client-1', 2); + await readChanged; + await capture('read'); + const turnCompleted = nextSummaryChange(); + stateManager.dispatchServerAction(chat, { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 1 }); + await turnCompleted; + await capture('turnCompleted'); + + assert.deepStrictEqual(snapshots, [ + { stage: 'created', state: initialModel.id, catalog: initialModel.id, listing: initialModel.id }, + ...['turnStarted', 'read', 'turnCompleted'].map(stage => ({ + stage, + state: nextModel.id, + catalog: nextModel.id, + listing: nextModel.id, + })), + ]); + }); + test('restricted pull-request associations replace the central payload without restoring removed links', async () => { const database = new TestSessionDatabase(); const catalogDatabase = new TestAgentHostOrchestratorDatabase(); @@ -1908,6 +1987,33 @@ suite('AgentService (node dispatcher)', () => { }); }); + test('rejects resume after restoring durable workspace quarantine', async () => { + const { session, chat } = await createErroredTurn(); + const turns = getStateManager(service).getChatState(chat)!.turns; + const database = new TestSessionDatabase(); + await database.setMetadata('agentHost.chatIsolationQuarantined', 'true'); + const restarted = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(database), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + registerTestAgentProvider(restarted, copilotAgent); + copilotAgent.chats.getMessages = async () => turns; + const resumeCalls: string[] = []; + copilotAgent.chats.resumeTurn = async (_chat, turnId) => { + resumeCalls.push(turnId); + }; + await restarted.restoreSession(session); + const envelopePromise = Event.toPromise(Event.filter(restarted.onDidAction, envelope => envelope.origin?.clientSeq === 1)); + restarted.dispatchAction(chat, { type: ActionType.ChatTurnResume, turnId: 'turn-1' }, 'client-1', 1); + const envelope = await envelopePromise; + assert.deepStrictEqual({ + rejectionReason: envelope.rejectionReason, + resumeCalls, + activeTurn: getStateManager(restarted).getChatState(chat)?.activeTurn, + }, { + rejectionReason: 'Cannot resume while workspace setup is pending or blocked.', + resumeCalls: [], + activeTurn: undefined, + }); + }); + test('rejects a non-resumable error without calling the provider', async () => { const { chat } = await createErroredTurn(false); const resumeCalls: string[] = []; @@ -2551,6 +2657,50 @@ suite('AgentService (node dispatcher)', () => { }); }); + test('registers workspace and isolation tools during provider creation before the session becomes ready', async () => { + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + setTestAgentHostWorktreeIsolation(svc, createTestAgentHostWorktreeIsolation({ supported: true })); + const observed: { lifecycle: SessionLifecycle | undefined; tools: string[] }[] = []; + class CreatingAgent extends MockAgent { + serverToolHost: IAgentServerToolHost | undefined; + + setServerToolHost(host: IAgentServerToolHost): void { + this.serverToolHost = host; + } + + async setChatWorkingDirectory(): Promise { } + + override readonly chats: IAgentChats = withChatOverrides(getChatSurface(this), base => ({ + createChat: async (chat, context, options) => { + const session = resolveAgentChatContext(context, chat).configurationResource.toString(); + observed.push({ + lifecycle: getStateManager(svc).getSessionState(session)?.lifecycle, + tools: this.serverToolHost!.getDefinitionsForSession(session) + .filter(tool => tool.name === SessionServerToolName.IsolateSession || tool.name === SessionServerToolName.SetWorkspace).map(tool => tool.name), + }); + return base.createChat(chat, context, options); + }, + })); + } + const agent = disposables.add(new CreatingAgent('copilot', undefined, { workspaceConversion: true })); + registerTestAgentProvider(svc, agent); + const session = await svc.createSession({ + provider: agent.id, + workingDirectories: [URI.file('/workspace/project')], + config: { [SessionConfigKey.Isolation]: 'folder' }, + }); + assert.deepStrictEqual({ + duringCreation: observed, + lifecycle: getStateManager(svc).getSessionState(session.toString())?.lifecycle, + advertised: getStateManager(svc).getSessionState(session.toString())?.serverTools + ?.filter(tool => tool.name === SessionServerToolName.IsolateSession || tool.name === SessionServerToolName.SetWorkspace).map(tool => tool.name), + }, { + duringCreation: [{ lifecycle: undefined, tools: [SessionServerToolName.SetWorkspace, SessionServerToolName.IsolateSession] }], + lifecycle: SessionLifecycle.Ready, + advertised: [SessionServerToolName.SetWorkspace, SessionServerToolName.IsolateSession], + }); + }); + test('marks worktree isolation pending before a provisional provider can prewarm', async () => { const session = AgentSession.uri('codex', 'pending-before-create'); const workingDirectory = URI.file('/workspace/repo'); @@ -3609,10 +3759,8 @@ suite('AgentService (node dispatcher)', () => { } } - async function setupTitleGeneration(copilotApiService: TestCopilotApiService): Promise<{ svc: AgentService; agent: TitleTestAgent; session: URI; db: TestSessionDatabase }> { - const db = new TestSessionDatabase(); - const sessionDataService = createSessionDataService(db); - const svc = disposables.add(createTestAgentService( + function createDeferredTitleTestAgentService(sessionDataService: ISessionDataService, copilotApiService?: ICopilotApiService): AgentService { + return createTestAgentService( new NullLogService(), fileService, sessionDataService, @@ -3622,7 +3770,23 @@ suite('AgentService (node dispatcher)', () => { undefined, undefined, copilotApiService, - )); + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + 'deferred', + ); + } + + async function setupTitleGeneration(copilotApiService: TestCopilotApiService): Promise<{ svc: AgentService; agent: TitleTestAgent; session: URI; db: TestSessionDatabase }> { + const db = new TestSessionDatabase(); + const sessionDataService = createSessionDataService(db); + const svc = disposables.add(createDeferredTitleTestAgentService(sessionDataService, copilotApiService)); const agent = new TitleTestAgent('copilot'); disposables.add(toDisposable(() => agent.dispose())); registerTestAgentProvider(svc, agent); @@ -4257,13 +4421,14 @@ suite('AgentService (node dispatcher)', () => { getStateManager(svc).registerChangeset(changeset); const rejectionPromise = Event.toPromise(Event.filter(svc.onDidAction, envelope => envelope.origin?.clientSeq === 1)); - svc.dispatchAction(changeset, { + const dispatch = svc.dispatchAction(changeset, { type: ActionType.ChangesetFilesReviewChanged, files: [URI.file('/workspace/file.txt').toString()], reviewed: true, }, 'test-client', 1); + assert.ok(dispatch instanceof Promise); + await dispatch; const rejection = await rejectionPromise; - await timeout(0); let nextAction: ActionEnvelope | undefined; const listener = svc.onDidAction(envelope => { @@ -4312,7 +4477,7 @@ suite('AgentService (node dispatcher)', () => { const clientId = 'ordered-client'; const chat = buildDefaultChatUri(session.toString()); - svc.dispatchAction(chat, { + const turnDispatch = svc.dispatchAction(chat, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', startedAt: '2025-01-01T00:00:00.000Z', @@ -4328,20 +4493,23 @@ suite('AgentService (node dispatcher)', () => { }, }, clientId, 1, AgentHostClientType.EditorWindow); await readStarted.p; - svc.dispatchAction(session.toString(), { + const directoryDispatch = svc.dispatchAction(session.toString(), { type: ActionType.SessionWorkingDirectorySet, directory: URI.file('/workspace/added').toString(), }, clientId, 2, AgentHostClientType.EditorWindow); + assert.ok(turnDispatch instanceof Promise && directoryDispatch instanceof Promise); + let completed = false; + void directoryDispatch.then(() => { completed = true; }); await timeout(0); assert.deepStrictEqual( - getStateManager(svc).getSessionState(session.toString())?.workingDirectories, - [primary.toString(), secondary.toString()], + { completed, workingDirectories: getStateManager(svc).getSessionState(session.toString())?.workingDirectories }, + { completed: false, workingDirectories: [primary.toString(), secondary.toString()] }, 'working-directory action must not overtake the pending rewrite', ); readGate.complete(); - await waitForCondition(() => observed.includes(ActionType.SessionWorkingDirectorySet), 'working-directory action should dispatch after the rewrite'); + await Promise.all([turnDispatch, directoryDispatch]); assert.ok( observed.indexOf(ActionType.ChatTurnStarted) < observed.indexOf(ActionType.SessionWorkingDirectorySet), `expected turn action before working-directory action, got ${observed.join(', ')}`, @@ -4530,7 +4698,7 @@ suite('AgentService (node dispatcher)', () => { for (const failFirstCreation of [false, true]) { test(`title strategy stays aligned with provider tools during creation (failure: ${failFirstCreation})`, async () => { const db = new TestSessionDatabase(); - const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const svc = disposables.add(createDeferredTitleTestAgentService(createSessionDataService(db))); const observed: ReturnType[number][] = []; let attempts = 0; class CreatingAgent extends TitleTestAgent { @@ -5060,6 +5228,490 @@ suite('AgentService (node dispatcher)', () => { }); suite('createSession', () => { + test('non-native providers retain allocation without native storage probes', async () => { + const fileServiceWithoutProviders = disposables.add(new FileService(new NullLogService())); + const svc = disposables.add(createTestAgentService( + new NullLogService(), fileServiceWithoutProviders, createSessionDataService(), + { _serviceBrand: undefined } as IProductService, createNoopGitService(), + )); + registerTestAgentProvider(svc, disposables.add(new MockAgent('custom-provider'))); + const session = await svc.createSession({ provider: 'custom-provider' }); + assert.deepStrictEqual({ + scheme: session.scheme, + provider: getStateManager(svc).getSessionSummary(session.toString())?.provider, + }, { scheme: 'custom-provider', provider: 'custom-provider' }); + }); + + class PersistedMockAgent extends MockAgent { + constructor(provider: string, private readonly sdkMetadata: Map) { + super(provider); + const createChat = this.chats.createChat; + this.chats.createChat = async (chat, context, options) => { + const result = await createChat(chat, context, options); + const metadata = await super.getChatMetadata(chat, context); + if (metadata) { + this.sdkMetadata.set(chat.toString(), metadata); + } + return result; + }; + } + + override async getChatMetadata(chat: URI, context: URI | IAgentChatContext): Promise { + return this.sdkMetadata.get(chat.toString()) ?? super.getChatMetadata(chat, context); + } + } + + test('orphaned backing markers cannot infer a provider from a standard resource', async () => { + const perSession = createPerSessionDataService(); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, perSession.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const session = URI.parse('ahp-session:/orphan-marker'); + await perSession.database(session).setMetadata('peerChatBacking', buildDefaultChatUri('ahp-session:/missing-owner')); + const inspect = svc as unknown as { + _isChatBacking(session: URI): Promise; + _readSessionRegistrationFacts(session: URI): Promise<{ chatBacking: boolean; hostCreated: boolean }>; + }; + assert.deepStrictEqual({ + backing: await inspect._isChatBacking(session), + facts: await inspect._readSessionRegistrationFacts(session), + }, { backing: false, facts: { chatBacking: false, hostCreated: false } }); + }); + + test('a backing marker shared by raw storage ID is scoped to its explicit provider', async () => { + const database = new TestSessionDatabase(); + await database.setMetadata('peerChatBacking', buildDefaultChatUri('ahp-session:/owner')); + await database.setMetadata('peerChatBackingProvider', 'codex'); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(database), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const inspect = svc as unknown as { + _isChatBacking(session: URI): Promise; + _readSessionRegistrationFacts(session: URI): Promise<{ chatBacking: boolean; hostCreated: boolean }>; + }; + const candidates = [URI.parse('codex:/shared-sdk-id'), URI.parse('claude:/shared-sdk-id')]; + assert.deepStrictEqual(await Promise.all(candidates.map(async session => ({ + backing: await inspect._isChatBacking(session), + facts: await inspect._readSessionRegistrationFacts(session), + }))), [ + { backing: true, facts: { chatBacking: true, hostCreated: false } }, + { backing: false, facts: { chatBacking: false, hostCreated: false } }, + ]); + }); + + test('registered standard identities route through their provider before restoration', async () => { + const database = new TestAgentHostOrchestratorDatabase(); + const resource = URI.parse('ahp-session:/registered'); + await database.registerRuntimeSession(resource.toString(), { provider: 'codex', startTime: 1000, modifiedTime: 2000, source: 'explicit' }, { checkTombstone: false }); + const svc = disposables.add(createTestAgentService( + new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService(), + undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, database, + )); + const agent = disposables.add(new MockAgent('codex')); + registerTestAgentProvider(svc, agent); + const read = svc as unknown as { _listRegisteredSessions(): Promise }; + await read._listRegisteredSessions(); + assert.deepStrictEqual({ + provider: getTestAgentHostProviderService(svc).getProviderForSession(resource)?.id, + resident: getStateManager(svc).getSessionState(resource.toString()) !== undefined, + }, { provider: 'codex', resident: false }); + }); + + for (const provider of ['copilotcli', 'codex', 'claude']) { + test(`${provider} standard default and forked peer chats retain addresses and provenance across restart`, async () => { + const perSession = createPerSessionDataService(); + const orchestratorDb = new TestAgentHostOrchestratorDatabase(); + const sdkMetadata = new Map(); + const createService = () => disposables.add(createTestAgentService( + new NullLogService(), fileService, perSession.service, + { _serviceBrand: undefined } as IProductService, createNoopGitService(), + undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, orchestratorDb, + )); + const createAgent = () => disposables.add(new class extends PersistedMockAgent { + override async createChat(_session: URI, _chat: URI): Promise { + return { providerData: 'peer-backing' }; + } + }(provider, sdkMetadata)); + const creating = createService(); + registerTestAgentProvider(creating, createAgent()); + const session = await creating.createSession({ provider }); + const defaultChat = buildDefaultChatUri(session); + const peer = URI.parse(buildChatUri(session, 'forked-peer')); + getStateManager(creating).dispatchServerAction(defaultChat, { type: ActionType.ChatTurnStarted, turnId: 'source-turn', startedAt: new Date().toISOString(), message: { text: 'Source', origin: { kind: MessageKind.User } } }); + getStateManager(creating).dispatchServerAction(defaultChat, { type: ActionType.ChatTurnComplete, turnId: 'source-turn', duration: 1 }); + await creating.createChat(session, peer, { fork: { source: URI.parse(defaultChat), turnId: 'source-turn' } }); + creating.dispose(); + const restarted = createService(); + registerTestAgentProvider(restarted, createAgent()); + await restarted.restoreSession(session); + assert.deepStrictEqual({ + resource: session.scheme, + defaultChat: getStateManager(restarted).getSessionState(session.toString())?.defaultChat, + chats: getStateManager(restarted).getSessionState(session.toString())?.chats.map(chat => ({ resource: chat.resource, origin: chat.origin ?? null })), + }, { + resource: 'ahp-session', + defaultChat, + chats: [{ resource: defaultChat, origin: { kind: ChatOriginKind.User } }, { resource: peer.toString(), origin: { kind: ChatOriginKind.Fork, chat: defaultChat, turnId: 'source-turn' } }], + }); + }); + } + + for (const provider of ['copilotcli', 'codex', 'claude']) { + for (const legacy of [false, true]) { + test(`${provider} host-generated allocations respect the initiating client's legacy addressing (${legacy})`, async () => { + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + registerTestAgentProvider(svc, disposables.add(new MockAgent(provider))); + const clientConnections = Reflect.get(svc, '_clientConnections') as IAgentHostClientConnectionService; + disposables.add(clientConnections.registerSource({ + hasSeenClient: () => true, + isClientConnected: () => true, + isLocalClient: () => false, + usesLegacySessionUris: () => legacy, + getConnectedClientTransportCounts: () => new Map(), + requestWorkspaceTrust: async () => true, + requestMcpAuthentication: async () => false, + })); + const resource = await svc.createSession({ provider, activeClient: { clientId: 'creator', tools: [], customizations: [] } }); + assert.deepStrictEqual([resource.scheme, getStateManager(svc).getSessionSummary(resource.toString())?.provider], [legacy ? provider : 'ahp-session', provider]); + }); + + for (const explicitChat of [false, true]) { + test(`${provider} source-turn initiator controls allocation addressing (${legacy}, explicit chat ${explicitChat})`, async () => { + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createPerSessionDataService().service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new MockAgent(provider)); + registerTestAgentProvider(svc, agent); + const clientConnections = Reflect.get(svc, '_clientConnections') as IAgentHostClientConnectionService; + disposables.add(clientConnections.registerSource({ + hasSeenClient: () => true, + isClientConnected: () => true, + isLocalClient: () => false, + usesLegacySessionUris: clientId => clientId === 'initiator' ? legacy : !legacy, + getConnectedClientTransportCounts: () => new Map(), + requestWorkspaceTrust: async () => true, + requestMcpAuthentication: async () => false, + })); + const parent = await svc.createSession({ provider }); + const sourceChat = buildDefaultChatUri(parent); + svc.dispatchAction(sourceChat, { + type: ActionType.ChatTurnStarted, + turnId: 'source-turn', + startedAt: new Date().toISOString(), + message: { text: 'Create another session', origin: { kind: MessageKind.User } }, + }, 'initiator', 1, AgentHostClientType.AgentsWindow); + await waitForCondition(() => agent.sendMessageCalls.length === 1, 'parent turn should reach the provider'); + const resource = await svc.createSession({ + provider, + activeClient: { clientId: 'different-client', tools: [], customizations: [] }, + _meta: { + 'agentHost/createdBySession': { + session: parent.toString(), + ...(explicitChat ? { chat: sourceChat, turnId: 'source-turn' } : {}), + } + }, + }); + assert.deepStrictEqual({ + scheme: resource.scheme, + provider: getStateManager(svc).getSessionSummary(resource.toString())?.provider, + source: readSessionCreationReference(getStateManager(svc).getSessionSummary(resource.toString())?._meta), + }, { + scheme: legacy ? provider : 'ahp-session', + provider, + source: { session: parent.toString(), ...(explicitChat ? { chat: sourceChat, turnId: 'source-turn' } : {}) }, + }); + }); + } + } + } + + test('new standard resources cannot reuse an existing provider raw-ID storage identity', async () => { + const perSession = createPerSessionDataService(); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, perSession.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + registerTestAgentProvider(svc, disposables.add(new MockAgent('codex'))); + registerTestAgentProvider(svc, disposables.add(new MockAgent('claude'))); + const legacy = URI.parse('codex:/already-stored'); + await svc.createSession({ provider: 'codex', session: legacy }); + await assert.rejects(svc.createSession({ provider: 'claude', session: URI.parse('ahp-session:/already-stored') }), /already in use/); + assert.deepStrictEqual((await svc.listSessions()).map(meta => [meta.session.toString(), meta.provider]), [[legacy.toString(), 'codex']]); + }); + + test('new standard allocations skip tombstones and occupied raw-ID directories', async () => { + const tombstoned: string[] = []; + const database = new class extends TestAgentHostOrchestratorDatabase { + override async isSessionTombstoned(session: string): Promise { + if (session.startsWith('ahp-session:') && tombstoned.length === 0) { + tombstoned.push(session); + await this.markSessionTombstoned(session); + } + return super.isSessionTombstoned(session); + } + }(); + const occupied: URI[] = []; + const collisionFileService = disposables.add(new class extends FileService { + override async exists(resource: URI): Promise { + if (resource.scheme === Schemas.inMemory && resource.path.startsWith('/allocation-data/') && occupied.length === 0) { + occupied.push(resource); + await this.createFolder(resource); + } + return super.exists(resource); + } + }(new NullLogService())); + disposables.add(collisionFileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); + const dataService: ISessionDataService = { + ...createPerSessionDataService().service, + getSessionDataDir: session => URI.from({ scheme: Schemas.inMemory, path: `/allocation-data/${AgentSession.id(session)}` }), + }; + const svc = disposables.add(createTestAgentService( + new NullLogService(), collisionFileService, dataService, { _serviceBrand: undefined } as IProductService, createNoopGitService(), + undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, database, + )); + registerTestAgentProvider(svc, disposables.add(new MockAgent('codex'))); + const session = await svc.createSession({ provider: 'codex' }); + assert.deepStrictEqual({ + scheme: session.scheme, + tombstoneSkipped: tombstoned.length === 1 && AgentSession.id(session) !== AgentSession.id(tombstoned[0]), + directorySkipped: occupied.length === 1 && dataService.getSessionDataDir(session).toString() !== occupied[0].toString(), + directoryPreserved: await collisionFileService.exists(occupied[0]), + }, { scheme: 'ahp-session', tombstoneSkipped: true, directorySkipped: true, directoryPreserved: true }); + }); + + for (const collision of ['directory', 'tombstone']) { + test(`an explicitly requested standard resource cannot reuse a ${collision}`, async () => { + const database = new TestAgentHostOrchestratorDatabase(); + const dataService: ISessionDataService = { + ...createPerSessionDataService().service, + getSessionDataDir: session => URI.from({ scheme: Schemas.inMemory, path: `/reserved-data/${AgentSession.id(session)}` }), + }; + const session = URI.parse('ahp-session:/reserved-id'); + if (collision === 'directory') { + await fileService.createFolder(dataService.getSessionDataDir(URI.parse('claude:/reserved-id'))); + } else { + await database.markSessionTombstoned(session.toString()); + } + const svc = disposables.add(createTestAgentService( + new NullLogService(), fileService, dataService, { _serviceBrand: undefined } as IProductService, createNoopGitService(), + undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, database, + )); + const agent = disposables.add(new MockAgent('codex')); + registerTestAgentProvider(svc, agent); + await assert.rejects(svc.createSession({ provider: 'codex', session }), /already in use/); + assert.deepStrictEqual({ providerCalled: agent.lastCreateSessionConfig !== undefined, sessions: await svc.listSessions() }, { providerCalled: false, sessions: [] }); + }); + } + + test('concurrent native allocations reserve distinct host-wide raw storage IDs', async () => { + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createPerSessionDataService().service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const providers = ['copilotcli', 'codex', 'claude']; + for (const provider of providers) { + registerTestAgentProvider(svc, disposables.add(new MockAgent(provider))); + } + const sessions = await Promise.all([...providers, ...providers].map(provider => svc.createSession({ provider }))); + assert.deepStrictEqual({ + schemes: [...new Set(sessions.map(session => session.scheme))], + uniqueStorageIds: new Set(sessions.map(session => AgentSession.id(session))).size, + registered: (await svc.listSessions()).length, + }, { schemes: ['ahp-session'], uniqueStorageIds: 6, registered: 6 }); + }); + + test('concurrent explicit standard creation cannot transfer provider ownership or backing data', async () => { + const perSession = createPerSessionDataService(); + const database = new TestAgentHostOrchestratorDatabase(); + const svc = disposables.add(createTestAgentService( + new NullLogService(), fileService, perSession.service, { _serviceBrand: undefined } as IProductService, createNoopGitService(), + undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, database, + )); + const entered = new DeferredPromise(); + const release = new DeferredPromise(); + const calls: string[] = []; + for (const provider of ['codex', 'claude']) { + const agent = disposables.add(new class extends MockAgent { + constructor() { + super(provider); + const createChat = this.chats.createChat; + this.chats.createChat = async (chat, context, options) => { + calls.push(provider); + entered.complete(); + await release.p; + const result = await createChat(chat, context, options); + return { ...(result ?? {}), providerData: `sdk-${provider}` }; + }; + } + }()); + registerTestAgentProvider(svc, agent); + } + const session = URI.parse('ahp-session:/explicit-owner'); + const first = svc.createSession({ provider: 'codex', session }); + await entered.p; + const second = svc.createSession({ provider: 'claude', session }); + const results = Promise.allSettled([first, second]); + await timeout(0); + const callsBeforeRelease = [...calls]; + release.complete(); + const outcomes = await results; + await svc.whenCatalogReconciliationIdle(); + assert.deepStrictEqual({ + callsBeforeRelease, + calls, + outcomes: outcomes.map(outcome => outcome.status), + provider: (await database.getSessionV2(session.toString()))?.provider, + backing: await perSession.database(session).getMetadata('defaultChatProviderData'), + }, { + callsBeforeRelease: ['codex'], + calls: ['codex'], + outcomes: ['fulfilled', 'rejected'], + provider: 'codex', + backing: 'sdk-codex', + }); + }); + + for (const firstStandard of [false, true]) { + test(`concurrent mixed-scheme creation protects one raw storage identity (${firstStandard})`, async () => { + const svc = disposables.add(createTestAgentService( + new NullLogService(), fileService, createPerSessionDataService().service, + { _serviceBrand: undefined } as IProductService, createNoopGitService(), + )); + const entered = new DeferredPromise(); + const release = new DeferredPromise(); + const calls: string[] = []; + const firstProvider = disposables.add(new class extends MockAgent { + constructor() { + super('codex'); + const createChat = this.chats.createChat; + this.chats.createChat = async (chat, context, options) => { + calls.push('codex'); + entered.complete(); + await release.p; + return createChat(chat, context, options); + }; + } + }()); + const secondProvider = disposables.add(new class extends MockAgent { + constructor() { + super('claude'); + const createChat = this.chats.createChat; + this.chats.createChat = async (chat, context, options) => { + calls.push('claude'); + return createChat(chat, context, options); + }; + } + }()); + registerTestAgentProvider(svc, firstProvider); + registerTestAgentProvider(svc, secondProvider); + const firstSession = URI.parse(`${firstStandard ? 'ahp-session' : 'codex'}:/mixed-owner`); + const secondSession = URI.parse(`${firstStandard ? 'claude' : 'ahp-session'}:/mixed-owner`); + const first = svc.createSession({ provider: 'codex', session: firstSession }); + await entered.p; + const second = svc.createSession({ provider: 'claude', session: secondSession }); + const results = Promise.allSettled([first, second]); + release.complete(); + const outcomes = await results; + assert.deepStrictEqual({ + calls, + outcomes: outcomes.map(outcome => outcome.status), + registered: (await svc.getRegisteredSessions()).map(resource => resource.toString()), + }, { calls: ['codex'], outcomes: ['fulfilled', 'rejected'], registered: [firstSession.toString()] }); + }); + } + + test('a same-provider explicit standard retry retains its registered identity', async () => { + const svc = disposables.add(createTestAgentService( + new NullLogService(), fileService, createPerSessionDataService().service, + { _serviceBrand: undefined } as IProductService, createNoopGitService(), + )); + registerTestAgentProvider(svc, disposables.add(new MockAgent('codex'))); + const session = URI.parse('ahp-session:/retry-owner'); + const resources = await Promise.all([ + svc.createSession({ provider: 'codex', session }), + svc.createSession({ provider: 'codex', session }), + ]); + assert.deepStrictEqual({ + resources: resources.map(resource => resource.toString()), + provider: getTestAgentHostProviderService(svc).getProviderForSession(session)?.id, + registered: (await svc.getRegisteredSessions()).map(resource => resource.toString()), + }, { resources: [session.toString(), session.toString()], provider: 'codex', registered: [session.toString()] }); + }); + + for (const provider of ['copilotcli', 'codex', 'claude']) { + test(`${provider} standard allocations do not rediscover their SDK backing before or after deletion`, async () => { + const perSession = createPerSessionDataService(); + const orchestratorDb = new TestAgentHostOrchestratorDatabase(); + const sdkMetadata = new Map(); + const createService = () => disposables.add(createTestAgentService( + new NullLogService(), fileService, perSession.service, { _serviceBrand: undefined } as IProductService, createNoopGitService(), + undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, orchestratorDb, + )); + const sdkBacking = AgentSession.uri(provider, `sdk-${provider}`); + const createAgent = () => disposables.add(new class extends PersistedMockAgent { + constructor() { + super(provider, sdkMetadata); + const createChat = this.chats.createChat; + this.chats.createChat = async (chat, context, options) => { + const result = await createChat(chat, context, options); + return { ...result, providerData: 'sdk-backing', backingSession: sdkBacking }; + }; + } + }()); + const svc = createService(); + const agent = createAgent(); + registerTestAgentProvider(svc, agent); + const session = await svc.createSession({ provider }); + const register = (svc as unknown as { _registerDiscoveredChats(provider: IAgent, chats: readonly IAgentDiscoveredChat[]): Promise })._registerDiscoveredChats; + await register.call(svc, agent, [discoveredChat(sdkBacking)]); + const before = (await svc.listSessions()).map(meta => meta.session.toString()); + svc.dispose(); + const restarted = createService(); + const restartedAgent = createAgent(); + registerTestAgentProvider(restarted, restartedAgent); + await restarted.restoreSession(session); + await register.call(restarted, restartedAgent, [discoveredChat(sdkBacking)]); + const afterRestart = (await restarted.listSessions()).map(meta => meta.session.toString()); + await restarted.disposeSession(session); + restarted.dispose(); + const afterDeletion = createService(); + const afterDeletionAgent = createAgent(); + registerTestAgentProvider(afterDeletion, afterDeletionAgent); + await register.call(afterDeletion, afterDeletionAgent, [discoveredChat(sdkBacking)]); + assert.deepStrictEqual({ + scheme: session.scheme, + before, + afterRestart, + after: (await afterDeletion.listSessions()).map(meta => meta.session.toString()), + }, { scheme: 'ahp-session', before: [session.toString()], afterRestart: [session.toString()], after: [] }); + }); + } + + for (const provider of ['copilotcli', 'codex', 'claude']) { + test(`${provider} preserves mixed immutable resources across listing and restart`, async () => { + const perSession = createPerSessionDataService(); + const orchestratorDb = new TestAgentHostOrchestratorDatabase(); + const sdkMetadata = new Map(); + const createService = () => disposables.add(createTestAgentService( + new NullLogService(), fileService, perSession.service, + { _serviceBrand: undefined } as IProductService, createNoopGitService(), + undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, orchestratorDb, + )); + const agent = disposables.add(new PersistedMockAgent(provider, sdkMetadata)); + const creating = createService(); + registerTestAgentProvider(creating, agent); + const legacy = URI.parse(`${provider}:/existing-${provider}`); + await creating.createSession({ provider, session: legacy }); + const standard = await creating.createSession({ provider }); + assert.strictEqual(standard.scheme, 'ahp-session'); + const before = await creating.listSessions(); + creating.dispose(); + const restarted = createService(); + registerTestAgentProvider(restarted, disposables.add(new PersistedMockAgent(provider, sdkMetadata))); + await restarted.restoreSession(legacy); + await restarted.restoreSession(standard); + const after = await restarted.listSessions(); + const project = (sessions: readonly IAgentSessionMetadata[]) => sessions.map(session => [session.session.toString(), session.provider]).sort(); + assert.deepStrictEqual({ + before: project(before), + after: project(after), + defaults: [legacy, standard].map(session => getStateManager(restarted).getSessionState(session.toString())?.defaultChat), + }, { + before: [[legacy.toString(), provider], [standard.toString(), provider]].sort(), + after: [[legacy.toString(), provider], [standard.toString(), provider]].sort(), + defaults: [legacy, standard].map(session => buildDefaultChatUri(session)), + }); + }); + } + test('remote session origin and cumulative depth survive creation, listing and host restoration', async () => { const perSession = createPerSessionDataService(); const orchestratorDb = new TestAgentHostOrchestratorDatabase(); @@ -5396,40 +6048,455 @@ suite('AgentService (node dispatcher)', () => { if (key === CHAT_PROVIDER_DATA_METADATA_KEY) { throw new Error('provider data write failed'); } - return super.setMetadata(key, value); + return super.setMetadata(key, value); + } + } + class BackedDefaultChatAgent extends MockAgent { + override readonly chats: IAgentChats = withChatOverrides(getChatSurface(this), base => ({ + createChat: async (chat, context, options) => { + const result = await base.createChat(chat, context, options); + return result ? { ...result, providerData: 'blob', backingSession: AgentSession.uri(this.id, 'default-chat-backing-sdk-id') } : result; + }, + })); + } + + const db = new FailingProviderDataDatabase(); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new BackedDefaultChatAgent('copilot')); + registerTestAgentProvider(svc, agent); + + await assert.rejects(() => svc.createSession({ provider: 'copilot' }), /provider data write failed/); + + assert.deepStrictEqual({ + registered: await svc.getRegisteredSessions(), + backingMarked: db.setMetadataCalls.some(c => c.key === 'peerChatBacking'), + providerDataPersisted: db.setMetadataCalls.some(c => c.key === CHAT_PROVIDER_DATA_METADATA_KEY), + disposeCalls: agent.disposeSessionCalls.length, + }, { + registered: [], + backingMarked: true, + providerDataPersisted: false, + disposeCalls: 1, + }); + }); + }); + + suite('additional chat working directories', () => { + for (const outcome of ['recreated', 'queued', 'interrupted', 'failed', 'quarantined'] as const) { + test(`workspace tool change preserves chat lifecycle and queued requests: ${outcome}`, async () => { + const moved = outcome === 'queued' || outcome === 'interrupted'; + const started = new DeferredPromise(); + const release = new DeferredPromise(); + const original = URI.file('/workspace/original'); + const destination = URI.file('/workspace/destination'); + const providerDirectories = new Map(); + let providerAligned = false; + class MultiChatAgent extends MockAgent { + serverToolHost: IAgentServerToolHost | undefined; + + setServerToolHost(host: IAgentServerToolHost): void { + this.serverToolHost = host; + } + + override async createChat(_session: URI, chat: URI, options?: IAgentCreateChatOptions): Promise { + providerDirectories.set(chat.toString(), (options?.workingDirectories?.[0] ?? original).toString()); + return { providerData: `backing:${chat.path}` }; + } + async setChatWorkingDirectory(chat: URI, _context: IAgentChatContext, directory: URI): Promise { + if (outcome === 'failed' || outcome === 'quarantined') { + started.complete(); + await release.p; + if (outcome === 'quarantined') { + throw new AgentWorkingDirectoryChangedError(directory, 'provider directory uncertain'); + } + throw new Error('provider rejected'); + } + providerDirectories.set(chat.toString(), directory.toString()); + providerAligned = true; + } + } + const perSession = createPerSessionDataService(); + const git = createNoopGitService(); + git.getSessionGitState = async directory => { + if (providerAligned && isEqual(directory, destination)) { + started.complete(); + await release.p; + } + return undefined; + }; + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, perSession.service, { _serviceBrand: undefined } as IProductService, git)); + const agent = disposables.add(new MultiChatAgent('copilot', { + multipleChats: { fork: true }, multipleWorkingDirectories: { immutablePrimary: true }, + }, { workspaceConversion: true })); + registerTestAgentProvider(svc, agent); + const session = await svc.createSession({ provider: agent.id, workingDirectories: [original] }); + const state = getStateManager(svc); + state.dispatchServerAction(session.toString(), { type: ActionType.SessionConfigChanged, config: { autoApprove: 'autoApprove' } }); + const peer = URI.parse(buildChatUri(session, 'peer')); + await svc.createChat(session, peer); + await svc.addSessionWorkingDirectoryForChat(session, destination, { isolation: 'folder' }); + const requesterTurn = 'request-turn'; + svc.dispatchAction(peer.toString(), { + type: ActionType.ChatTurnStarted, turnId: requesterTurn, startedAt: new Date().toISOString(), + message: { text: 'Work in the destination', origin: { kind: MessageKind.User } }, + }, 'client', 1); + await waitForCondition(() => agent.sendMessageCalls.length === 1, 'Expected the requesting turn to start'); + await agent.serverToolHost!.executeTool(peer.toString(), SessionServerToolName.SetWorkspace, { workspaceFolder: destination.toString(), isolation: false }); + const rejections: (string | undefined)[] = []; + disposables.add(svc.onDidAction(envelope => envelope.rejectionReason && rejections.push(envelope.rejectionReason))); + agent.fireProgress({ kind: 'action', resource: peer, action: { type: ActionType.ChatTurnComplete, turnId: requesterTurn, duration: 1 } }); + await started.p; + if (outcome === 'recreated') { + await svc.disposeChat(session, peer); + await perSession.database(peer).deleteMetadata(['agentHost.chatIsolationQuarantined']); + await svc.createChat(session, peer); + state.dispatchServerAction(peer.toString(), { type: ActionType.ChatActivityChanged, activity: 'Replacement activity' }); + } else if (outcome === 'interrupted') { + svc.dispatchAction(peer.toString(), { type: ActionType.ChatTurnCancelled, turnId: state.getActiveTurnId(peer.toString())!, duration: 0 }, 'client', 2); + svc.dispatchAction(peer.toString(), { type: ActionType.ChatTruncated }, 'client', 3); + svc.dispatchAction(peer.toString(), { type: ActionType.ChatIsArchivedChanged, isArchived: true }, 'client', 4); + svc.dispatchAction(session.toString(), { type: ActionType.SessionIsArchivedChanged, isArchived: true }, 'client', 5); + svc.dispatchAction(peer.toString(), { + type: ActionType.ChatPendingMessageSet, + id: 'steer-user', kind: PendingMessageKind.Steering, + message: { text: 'Steer now', origin: { kind: MessageKind.User } }, + }, 'client', 6); + await waitForCondition(() => rejections.length === 5, 'Expected every disruptive request to be rejected'); + svc.dispatchAction(peer.toString(), { + type: ActionType.ChatTurnStarted, + turnId: 'stray-turn', + startedAt: new Date().toISOString(), + message: { text: 'Stray message', origin: { kind: MessageKind.User } }, + }, 'client', 7); + await waitForCondition(() => rejections.length === 6, 'Expected a stray turn start to be rejected'); + } else { + svc.dispatchAction(peer.toString(), { + type: ActionType.ChatPendingMessageSet, + id: 'queued-user', kind: PendingMessageKind.Queued, + message: { text: 'Next user task', origin: { kind: MessageKind.User } }, + }, 'client', 2); + } + const persistedBeforeCompletion = await perSession.database(peer).getMetadata(CHAT_WORKING_DIRECTORIES_METADATA_KEY); + release.complete(); + const completeActiveTurn = () => agent.fireProgress({ + kind: 'action', resource: peer, action: { type: ActionType.ChatTurnComplete, turnId: state.getActiveTurnId(peer.toString())!, duration: 1 }, + }); + if (moved || outcome === 'failed') { + await waitForCondition(() => agent.sendMessageCalls.length === 2, 'Expected the host continuation to be sent'); + completeActiveTurn(); + } else { + await timeout(0); + } + if (outcome === 'queued' || outcome === 'failed') { + await waitForCondition(() => agent.sendMessageCalls.length === 3, 'Expected the queued message to be sent after the continuation'); + } + assert.deepStrictEqual({ + directory: state.getChatState(peer.toString())?.workingDirectories ?? [state.getSessionSummary(session.toString())!.workingDirectories![0]], + providerDirectory: providerDirectories.get(peer.toString()), + persistedDirectory: await perSession.database(peer).getMetadata(CHAT_WORKING_DIRECTORIES_METADATA_KEY), + activity: outcome === 'recreated' ? state.getChatState(peer.toString())?.activity : undefined, + queued: state.getChatState(peer.toString())?.queuedMessages?.map(message => message.id) ?? [], + sends: agent.sendMessageCalls.map((call, index) => index === 0 ? 'request' + : /Only this chat now uses the workspace/.test(call.prompt) ? 'workspace continuation' + : /did not complete successfully/.test(call.prompt) ? 'failure continuation' : call.prompt), + queuedSender: outcome === 'queued' || outcome === 'failed' ? agent.sendMessageCalls.at(-1)?.senderClientId : undefined, + quarantined: await perSession.database(peer).getMetadata('agentHost.chatIsolationQuarantined') === 'true', + rejections, + }, { + directory: [moved ? destination.toString() : original.toString()], + providerDirectory: moved ? destination.toString() : original.toString(), + persistedDirectory: moved ? JSON.stringify([destination.toString()]) : persistedBeforeCompletion, + activity: outcome === 'recreated' ? 'Replacement activity' : undefined, + queued: outcome === 'quarantined' ? ['queued-user'] : [], + sends: outcome === 'queued' ? ['request', 'workspace continuation', 'Next user task'] + : outcome === 'interrupted' ? ['request', 'workspace continuation'] + : outcome === 'failed' ? ['request', 'failure continuation', 'Next user task'] + : ['request'], + queuedSender: outcome === 'queued' || outcome === 'failed' ? 'client' : undefined, + quarantined: outcome === 'quarantined', + rejections: outcome === 'interrupted' ? [ + 'Cannot cancel while the workspace is changing.', + 'Cannot clear history while the workspace is changing.', + 'Cannot archive a chat while its workspace is changing.', + 'Cannot archive a session while a workspace is changing.', + 'Cannot steer while the workspace is changing.', + 'Cannot send a message while the workspace is changing.', + ] : [], + }); + }); + } + + for (const scenario of ['single', 'sibling', 'unchanged'] as const) { + test(`set_workspace on a session that already has a workspace: ${scenario}`, async () => { + const original = URI.file('/workspace/original'); + const destination = URI.file('/workspace/destination'); + const calls: { chat: string; directory: string; replaceSessionWorkspace: boolean | undefined }[] = []; + class WorkspaceAgent extends MockAgent { + serverToolHost: IAgentServerToolHost | undefined; + + setServerToolHost(host: IAgentServerToolHost): void { + this.serverToolHost = host; + } + + override async createChat(_session: URI, chat: URI): Promise { + return { providerData: `backing:${chat.path}` }; + } + async setChatWorkingDirectory(chat: URI, _context: IAgentChatContext, directory: URI, options?: { readonly replaceSessionWorkspace: boolean }): Promise { + calls.push({ chat: chat.toString(), directory: directory.toString(), replaceSessionWorkspace: options?.replaceSessionWorkspace }); + } + } + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createPerSessionDataService().service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new WorkspaceAgent('copilot', { multipleChats: { fork: true }, multipleWorkingDirectories: { immutablePrimary: true } }, { workspaceConversion: true })); + registerTestAgentProvider(svc, agent); + const session = await svc.createSession({ provider: agent.id, workingDirectories: [original] }); + const state = getStateManager(svc); + state.dispatchServerAction(session.toString(), { type: ActionType.SessionConfigChanged, config: { autoApprove: 'autoApprove' } }); + const main = buildDefaultChatUri(session); + const peer = buildChatUri(session, 'peer'); + if (scenario === 'sibling') { + await svc.createChat(session, URI.parse(peer)); + } + const toolsBefore = agent.serverToolHost!.getDefinitionsForSession(session.toString()).map(tool => tool.name).filter(name => name === SessionServerToolName.SetWorkspace); + svc.dispatchAction(main, { + type: ActionType.ChatTurnStarted, turnId: 'request-turn', startedAt: new Date().toISOString(), + message: { text: 'Work in the destination', origin: { kind: MessageKind.User } }, + }, 'client', 1); + await waitForCondition(() => agent.sendMessageCalls.length === 1, 'Expected the requesting turn to start'); + const result = await agent.serverToolHost!.executeTool(main, SessionServerToolName.SetWorkspace, { + workspaceFolder: (scenario === 'unchanged' ? original : destination).toString(), isolation: false, + }); + agent.fireProgress({ kind: 'action', resource: URI.parse(main), action: { type: ActionType.ChatTurnComplete, turnId: 'request-turn', duration: 1 } }); + if (scenario !== 'unchanged') { + await waitForCondition(() => agent.sendMessageCalls.length === 2, 'Expected the host to continue the original task'); + } else { + await timeout(0); + } + const chats = state.getSessionState(session.toString())!.chats; + assert.deepStrictEqual({ + toolsBefore, + result: result.split('.')[0], + calls, + sessionDirectories: state.getSessionSummary(session.toString())?.workingDirectories, + chatDirectories: chats.map(chat => [chat.resource, chat.workingDirectories]), + sends: agent.sendMessageCalls.length, + continuation: agent.sendMessageCalls[1]?.prompt.replace(/ at \S+\./, ' at .').split('. ')[0], + }, { + toolsBefore: [SessionServerToolName.SetWorkspace], + result: scenario === 'unchanged' ? `This chat already uses ${original.toString()}` : `Workspace will be set to ${destination.toString()} after this turn ends`, + calls: scenario === 'unchanged' ? [] : [{ chat: main, directory: destination.toString(), replaceSessionWorkspace: scenario === 'single' ? true : undefined }], + sessionDirectories: scenario === 'single' ? [destination.toString()] : scenario === 'sibling' ? [original.toString(), destination.toString()] : [original.toString()], + chatDirectories: scenario === 'single' ? [[main, [destination.toString()]]] + : scenario === 'sibling' ? [[main, [destination.toString()]], [peer, [original.toString()]]] + : [[main, undefined]], + sends: scenario === 'unchanged' ? 1 : 2, + continuation: scenario === 'unchanged' ? undefined : 'Only this chat now uses the workspace at ', + }); + }); + } + + test('workspace finalization serializes peer disposal across metadata persistence', async () => { + const started = new DeferredPromise(); + const release = new DeferredPromise(); + const original = URI.file('/workspace/original'); + const destination = URI.file('/workspace/destination'); + let gate = false; + const disposed: string[] = []; + class GatedDatabase extends TestSessionDatabase { + override async setMetadataValues(values: Readonly>): Promise { + if (gate && values[CHAT_WORKING_DIRECTORIES_METADATA_KEY] === JSON.stringify([destination.toString()])) { + gate = false; + started.complete(); + await release.p; + } + await super.setMetadataValues(values); + } + } + class MultiChatAgent extends MockAgent { + override async createChat(_session: URI, chat: URI): Promise { + return { providerData: `backing:${chat.path}` }; + } + override async disposeChat(_session: URI, chat: URI): Promise { + disposed.push(chat.toString()); + } + } + const database = new GatedDatabase(); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(database), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new MultiChatAgent('copilot', { + multipleChats: { fork: true }, multipleWorkingDirectories: { immutablePrimary: true }, + })); + registerTestAgentProvider(svc, agent); + const session = await svc.createSession({ provider: agent.id, workingDirectories: [original] }); + const peer = URI.parse(buildChatUri(session, 'peer')); + await svc.createChat(session, peer); + await svc.addSessionWorkingDirectoryForChat(session, destination, { isolation: 'folder' }); + gate = true; + const changing = svc.setChatWorkingDirectory(session, peer, destination); + await started.p; + const deleting = svc.disposeChat(session, peer); + await Promise.resolve(); + const disposedWhilePersisting = [...disposed]; + release.complete(); + await Promise.all([changing, deleting]); + await svc.createChat(session, peer); + assert.deepStrictEqual({ + disposedWhilePersisting, + disposed, + workspace: getStateManager(svc).getChatState(peer.toString())?.workingDirectories, + persisted: await database.getMetadata(CHAT_WORKING_DIRECTORIES_METADATA_KEY), + }, { disposedWhilePersisting: [], disposed: [peer.toString()], workspace: undefined, persisted: '' }); + }); + + test('single-chat workspace replacement waits for an in-flight chat creation and refuses to move its workspace', async () => { + const started = new DeferredPromise(); + const release = new DeferredPromise(); + class MultiChatAgent extends MockAgent { + override async createChat(_session: URI, chat: URI): Promise { + if (!isDefaultChatUri(chat)) { + started.complete(); + await release.p; + } + return { providerData: `backing:${chat.path}` }; } } - class BackedDefaultChatAgent extends MockAgent { - override readonly chats: IAgentChats = withChatOverrides(getChatSurface(this), base => ({ - createChat: async (chat, context, options) => { - const result = await base.createChat(chat, context, options); - return result ? { ...result, providerData: 'blob', backingSession: AgentSession.uri(this.id, 'default-chat-backing-sdk-id') } : result; - }, - })); - } - - const db = new FailingProviderDataDatabase(); - const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); - const agent = disposables.add(new BackedDefaultChatAgent('copilot')); + const perSession = createPerSessionDataService(); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, perSession.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new MultiChatAgent('copilot', { multipleChats: { fork: true } })); registerTestAgentProvider(svc, agent); + const original = URI.file('/workspace/original'); + const session = await svc.createSession({ provider: agent.id, workingDirectories: [original] }); + const main = URI.parse(buildDefaultChatUri(session)); + const peer = URI.parse(buildChatUri(session, 'peer')); + const creating = svc.createChat(session, peer); + await started.p; + const replacing = svc.runWithChatCatalogLock(session, () => svc.setChatWorkingDirectory(session, main, URI.file('/workspace/worktree'), true)); + const rejected = assert.rejects(replacing, /Cannot assign/); + release.complete(); + await Promise.all([creating, rejected]); + assert.deepStrictEqual({ + workspace: getStateManager(svc).getSessionSummary(session.toString())?.workingDirectories, + chats: getStateManager(svc).getSessionState(session.toString())?.chats.map(chat => chat.resource), + }, { workspace: [original.toString()], chats: [main.toString(), peer.toString()] }); + }); - await assert.rejects(() => svc.createSession({ provider: 'copilot' }), /provider data write failed/); - + test('replaces a single-chat workspace without multi-root support and persists the chat scope', async () => { + const perSession = createPerSessionDataService(); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, perSession.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new MockAgent('copilot')); + registerTestAgentProvider(svc, agent); + const original = URI.file('/workspace/original'); + const worktree = URI.file('/workspace/original.worktrees/isolated'); + const session = await svc.createSession({ provider: agent.id, workingDirectories: [original] }); + const main = URI.parse(buildDefaultChatUri(session)); + await svc.setChatWorkingDirectory(session, main, worktree, true); + const state = getStateManager(svc); assert.deepStrictEqual({ - registered: await svc.getRegisteredSessions(), - backingMarked: db.setMetadataCalls.some(c => c.key === 'peerChatBacking'), - providerDataPersisted: db.setMetadataCalls.some(c => c.key === CHAT_PROVIDER_DATA_METADATA_KEY), - disposeCalls: agent.disposeSessionCalls.length, + aggregate: state.getSessionSummary(session.toString())?.workingDirectories, + chat: state.getChatState(main.toString())?.workingDirectories, + catalog: state.getSessionState(session.toString())?.chats.map(chat => chat.workingDirectories), + persisted: await perSession.database(main).getMetadata(CHAT_WORKING_DIRECTORIES_METADATA_KEY), + persistedAggregate: await perSession.database(session).getMetadata(SESSION_WORKING_DIRECTORIES_KEY), }, { - registered: [], - backingMarked: true, - providerDataPersisted: false, - disposeCalls: 1, + aggregate: [worktree.toString()], chat: [worktree.toString()], + catalog: [[worktree.toString()]], persisted: JSON.stringify([worktree.toString()]), + persistedAggregate: JSON.stringify([worktree.toString()]), }); }); - }); - suite('additional chat working directories', () => { + for (const isolateMain of [false, true]) { + test(`persists and publishes only the ${isolateMain ? 'main' : 'peer'} chat's replacement workspace`, async () => { + class MultiChatAgent extends MockAgent { + override async createChat(_session: URI, chat: URI): Promise { + return { providerData: `backing:${chat.path}` }; + } + } + const perSession = createPerSessionDataService(); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, perSession.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new MultiChatAgent('copilot', { + multipleChats: { fork: true }, multipleWorkingDirectories: { immutablePrimary: true }, + })); + registerTestAgentProvider(svc, agent); + const original = URI.file('/workspace/original'); + const worktree = URI.file('/workspace/original.worktrees/isolated'); + const session = await svc.createSession({ provider: agent.id, workingDirectories: [original] }); + const main = URI.parse(buildDefaultChatUri(session)); + const peer = URI.parse(buildChatUri(session, 'peer')); + await svc.createChat(session, peer); + await perSession.database(session).setMetadata(META_DIFF_BASE_BRANCH, 'release-A'); + await perSession.database(peer).setMetadata(META_DIFF_BASE_BRANCH, 'release-A'); + await svc.setChatWorkingDirectory(session, peer, original); + const unchangedBaseBranch = await perSession.database(peer).getMetadata(META_DIFF_BASE_BRANCH); + await svc.addSessionWorkingDirectoryForChat(session, worktree, { isolation: 'folder' }); + const target = isolateMain ? main : peer; + const other = isolateMain ? peer : main; + await assert.rejects(svc.setChatWorkingDirectory(session, target, worktree, true), /Cannot assign/); + await svc.setChatWorkingDirectory(session, target, worktree); + const state = getStateManager(svc); + assert.deepStrictEqual({ + aggregate: state.getSessionSummary(session.toString())?.workingDirectories, + target: state.getChatState(target.toString())?.workingDirectories, + other: state.getChatState(other.toString())?.workingDirectories, + summaries: state.getSessionState(session.toString())?.chats.map(chat => [chat.resource, chat.workingDirectories]), + persistedTarget: await perSession.database(target).getMetadata(CHAT_WORKING_DIRECTORIES_METADATA_KEY), + persistedOther: await perSession.database(other).getMetadata(CHAT_WORKING_DIRECTORIES_METADATA_KEY), + persistedAggregate: await perSession.database(session).getMetadata(SESSION_WORKING_DIRECTORIES_KEY), + baseBranches: await Promise.all([session, peer].map(resource => perSession.database(resource).getMetadata(META_DIFF_BASE_BRANCH))), + unchangedBaseBranch, + }, { + aggregate: [original.toString(), worktree.toString()], + target: [worktree.toString()], other: [original.toString()], + summaries: [main, peer].map(chat => [chat.toString(), [chat.toString() === target.toString() ? worktree.toString() : original.toString()]]), + persistedTarget: JSON.stringify([worktree.toString()]), + persistedOther: JSON.stringify([original.toString()]), + persistedAggregate: JSON.stringify([original.toString(), worktree.toString()]), + baseBranches: ['release-A', isolateMain ? 'release-A' : ''], + unchangedBaseBranch: 'release-A', + }); + }); + } + + for (const provider of ['copilot', 'codex']) { + for (const exclusive of [false, true]) { + test(`restores ${provider} aggregate primary independently of the main chat (exclusive: ${exclusive})`, async () => { + class MultiChatAgent extends MockAgent { + override async createChat(_session: URI, chat: URI): Promise { + return { providerData: `backing:${chat.path}` }; + } + } + const perSession = createPerSessionDataService(); + const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, perSession.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new MultiChatAgent(provider, { + multipleChats: { fork: true }, multipleWorkingDirectories: { immutablePrimary: true }, + })); + registerTestAgentProvider(svc, agent); + const original = URI.file('/workspace/original'); + const replacement = URI.file('/workspace/replacement'); + const session = await svc.createSession({ provider, workingDirectories: [original] }); + const main = URI.parse(buildDefaultChatUri(session)); + const peer = URI.parse(buildChatUri(session, 'peer')); + if (!exclusive) { + await svc.createChat(session, peer); + await svc.addSessionWorkingDirectoryForChat(session, replacement, { isolation: 'folder' }); + } + await svc.setChatWorkingDirectory(session, main, replacement, exclusive); + agent.sessionMetadataOverrides = { workingDirectories: [replacement] }; + getStateManager(svc).deleteSession(session.toString()); + const listed = (await svc.listSessions()).find(metadata => metadata.session.toString() === session.toString()); + await svc.restoreSession(session); + assert.deepStrictEqual({ + listed: listed?.workingDirectories?.map(directory => directory.toString()), + aggregate: getStateManager(svc).getSessionSummary(session.toString())?.workingDirectories, + main: getStateManager(svc).getChatState(main.toString())?.workingDirectories, + peer: getStateManager(svc).getSessionState(session.toString())?.chats.find(chat => chat.resource === peer.toString())?.workingDirectories, + }, { + listed: (exclusive ? [replacement] : [original, replacement]).map(directory => directory.toString()), + aggregate: (exclusive ? [replacement] : [original, replacement]).map(directory => directory.toString()), + main: [replacement.toString()], + peer: exclusive ? undefined : [original.toString()], + }); + }); + } + } + test('adds a folder to the session before assigning it to a new chat', async () => { class MultiChatAgent extends MockAgent { override async createChat(_session: URI, chat: URI): Promise { @@ -6181,6 +7248,158 @@ suite('AgentService (node dispatcher)', () => { // ---- disposeSession ------------------------------------------------- suite('disposeSession', () => { + for (const [operation, available] of [['retry', true], ['delete', true], ['delete', false]] as const) { + test(`identity operations preserve unresolved legacy provenance (${operation}, ${available})`, async () => { + const database = new class extends TestAgentHostOrchestratorDatabase { + unresolved = false; + override async getSessionV2Registration(session: string): Promise { + const registered = await super.getSessionV2Registration(session); + return registered && this.unresolved ? { ...registered, external: undefined } : registered; + } + override async registerRuntimeSession(session: string, options: IAgentHostDatabaseSessionOptions, registerOptions: IAgentHostDatabaseRegisterOptions): Promise { + this.unresolved = false; + return super.registerRuntimeSession(session, options, registerOptions); + } + }(); + const session = URI.parse('codex:/legacy-unresolved'); + await database.registerRuntimeSession(session.toString(), { provider: 'codex', startTime: 1000, source: 'restore' }, { checkTombstone: false }); + database.unresolved = true; + const deleted: string[] = []; + const perSession = createPerSessionDataService(); + await perSession.database(session).setMetadata('defaultChatProviderData', 'legacy-backing'); + const svc = disposables.add(createTestAgentService( + new NullLogService(), fileService, + { ...perSession.service, deleteSessionData: async resource => { deleted.push(resource.toString()); } }, + { _serviceBrand: undefined } as IProductService, createNoopGitService(), + undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, database, + )); + const agent = disposables.add(new MockAgent('codex')); + if (available) { + registerTestAgentProvider(svc, agent); + } + assert.strictEqual((await database.getSessionV2Registration(session.toString()))?.external, undefined); + if (operation === 'retry') { + assert.strictEqual((await svc.createSession({ provider: 'codex', session })).toString(), session.toString()); + } else if (available) { + await svc.disposeSession(session); + } else { + await assert.rejects(svc.disposeSession(session), /No agent provider registered/); + } + assert.deepStrictEqual({ + provider: (await database.getSessionV2Registration(session.toString()))?.provider, + disposed: agent.chatContexts.filter(entry => entry.boundary === 'disposeChat').map(entry => entry.chat.toString()), + deleted, + tombstoned: await database.isSessionTombstoned(session.toString()), + backingMetadata: await perSession.database(session).getMetadata('defaultChatProviderData'), + }, operation === 'delete' && available ? { + provider: undefined, + disposed: [buildDefaultChatUri(session)], + deleted: [buildDefaultChatUri(session), session.toString()], + tombstoned: true, + backingMetadata: 'legacy-backing', + } : { + provider: 'codex', + disposed: [], + deleted: [], + tombstoned: false, + backingMetadata: 'legacy-backing', + }); + }); + } + + for (const provider of ['copilotcli', 'codex', 'claude']) { + for (const [available, materializeFailure] of [[false, undefined], [true, undefined], [true, 'peer'], [true, 'default']] as const) { + test(`${provider} cold deletion resolves persisted ownership and preserves data when unavailable (${available}, ${materializeFailure ?? 'none'})`, async () => { + const perSession = createPerSessionDataService(); + const database = new TestAgentHostOrchestratorDatabase(); + const deletedResources: string[] = []; + const dataService: ISessionDataService = { + ...perSession.service, + deleteSessionData: async resource => { deletedResources.push(resource.toString()); }, + }; + const createService = () => disposables.add(createTestAgentService( + new NullLogService(), fileService, dataService, { _serviceBrand: undefined } as IProductService, createNoopGitService(), + undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, database, + )); + const backings = new Set(); + const disposed: { chat: string; backing: string }[] = []; + class BackingAgent extends MockAgent { + private readonly bound = new Map(); + override async createChat(): Promise { return {}; } + constructor() { + super(provider); + const createChat = this.chats.createChat; + const disposeChat = this.chats.disposeChat; + this.chats.createChat = async (chat, context, options) => { + const result = await createChat(chat, context, options); + const backing = isDefaultChatUri(chat) ? 'sdk-default' : 'sdk-peer'; + backings.add(backing); + this.bound.set(chat.toString(), backing); + return { ...(result ?? {}), providerData: backing }; + }; + this.chats.disposeChat = async (chat, context) => { + const backing = this.bound.get(chat.toString()); + if (!backing) { + throw new Error('Cold backing was not materialized'); + } + disposed.push({ chat: chat.toString(), backing }); + backings.delete(backing); + await disposeChat(chat, context); + }; + } + override async materializeChat(chat: URI, _context: URI | IAgentChatContext, providerData: string | undefined): Promise { + if (materializeFailure === (isDefaultChatUri(chat) ? 'default' : 'peer')) { + throw new Error('Cold provider backing is unavailable'); + } + assert.ok(providerData); + this.bound.set(chat.toString(), providerData); + } + } + const creating = createService(); + registerTestAgentProvider(creating, disposables.add(new BackingAgent())); + const session = await creating.createSession({ provider }); + const peer = URI.parse(buildChatUri(session, 'peer')); + await creating.createChat(session, peer); + await creating.whenCatalogReconciliationIdle(); + creating.dispose(); + const deleting = createService(); + if (available) { + registerTestAgentProvider(deleting, disposables.add(new BackingAgent())); + } + assert.strictEqual(getTestAgentHostProviderService(deleting).getProviderForSession(session), undefined); + if (available && !materializeFailure) { + await deleting.disposeSession(session); + } else if (materializeFailure) { + await assert.rejects(deleting.disposeSession(session), /Cold provider backing is unavailable/); + } else { + await assert.rejects(deleting.disposeSession(session), /No agent provider registered/); + } + assert.deepStrictEqual({ + disposed, + backings: [...backings], + deletedResources, + tombstoned: await database.isSessionTombstoned(session.toString()), + registered: (await database.getSessionV2(session.toString()))?.provider, + backingMetadata: await perSession.database(session).getMetadata('defaultChatProviderData'), + }, available && !materializeFailure ? { + disposed: [{ chat: peer.toString(), backing: 'sdk-peer' }, { chat: buildDefaultChatUri(session), backing: 'sdk-default' }], + backings: [], + deletedResources: [peer.toString(), buildDefaultChatUri(session), session.toString()], + tombstoned: true, + registered: undefined, + backingMetadata: 'sdk-default', + } : { + disposed: [], + backings: ['sdk-default', 'sdk-peer'], + deletedResources: [], + tombstoned: false, + registered: provider, + backingMetadata: 'sdk-default', + }); + }); + } + } + test('dispatches to the correct provider and cleans up tracking', async () => { registerTestAgentProvider(service, copilotAgent); @@ -10701,6 +11920,49 @@ suite('AgentService (node dispatcher)', () => { }); }); + test('ordinary Codex discovery backfills a missing catalog model without a recency change', async () => { + class CodexDiscoveryAgent extends DirectImportAgent { + latest: IAgentChatMetadata | undefined; + + override async getChatMetadata(chat: URI, context: URI | IAgentChatContext): Promise { + return this.latest ?? super.getChatMetadata(chat, context); + } + } + const database = new TransientRegistryWriteDatabase(); + const perSession = createPerSessionDataService(); + const svc = createService(database, perSession.service); + const agent = disposables.add(new CodexDiscoveryAgent('codex')); + const session = AgentSession.uri('codex', 'model-metadata-backfill'); + const modifiedTime = Date.now(); + agent.catalog = [{ ...metadata(session), modifiedTime }]; + registerTestAgentProvider(svc, agent); + await waitForInitialProviderMigration(svc, agent); + const before = readCodexSessionModel(catalogDataOf(await database.getSessionV2(session.toString()))); + + const latest = { + ...metadata(session, withCodexSessionModel(undefined, { id: '@provider=openai:gpt-5.6-sol' })), + modifiedTime, + }; + agent.latest = latest; + await (svc as unknown as { + _registerDiscoveredChats(provider: IAgent, chats: readonly IAgentDiscoveredChat[]): Promise; + })._registerDiscoveredChats(agent, [{ + ...latest, + external: true, + }]); + await (svc as unknown as { + _catalogReconciliationService: { runPass(): Promise }; + })._catalogReconciliationService.runPass(); + + assert.deepStrictEqual({ + before, + after: readCodexSessionModel(catalogDataOf(await database.getSessionV2(session.toString()))), + }, { + before: undefined, + after: { id: '@provider=openai:gpt-5.6-sol' }, + }); + }); + test('fulfilled incomplete initial import retries provider-only candidates without another provider event', async () => { const database = new TransientRegistryWriteDatabase(); const perSession = createPerSessionDataService(); @@ -11220,6 +12482,39 @@ suite('AgentService (node dispatcher)', () => { }); }); + test('full reconciliation without a local database keeps cached non-empty aggregate roots', async () => { + const database = new TransientRegistryWriteDatabase(); + const perSession = createPerSessionDataService(); + const session = AgentSession.uri('copilot', 'central-aggregate-roots'); + const aggregate = [URI.file('/provider/workspace').toString(), URI.file('/extra/root').toString()]; + await database.registerSessionV2(session.toString(), { provider: 'copilot', startTime: 1, source: 'discovery' }, { checkTombstone: true }); + assert.strictEqual(await database.upsertSessionV2(catalogEnvelope(session, { + modifiedTime: 1, + summary: 'Cached title', + isRead: true, + isArchived: false, + workingDirectories: aggregate, + chats: [{ uri: buildDefaultChatUri(session), order: 0, kind: 'default' }], + }, 'external-generation', 0), undefined), 'applied'); + await database.markSessionV2PayloadDirty(session.toString()); + const svc = createService(database, perSession.service); + const agent = disposables.add(new DirectImportAgent('copilot')); + agent.sessionMetadataOverrides = { + modifiedTime: 2, + summary: 'Provider title', + status: SessionStatus.Idle, + workingDirectories: [URI.file('/provider/workspace')], + }; + await createAgentSession(agent, { session }); + registerTestAgentProvider(svc, agent); + + await (svc as unknown as { + _catalogReconciliationService: { runFullPass(): Promise }; + })._catalogReconciliationService.runFullPass(); + + assert.deepStrictEqual(catalogDataOf(await database.getSessionV2(session.toString()))?.workingDirectories, aggregate); + }); + test('cached peer stays listed while restore enriches its backing', async () => { const database = new TransientRegistryWriteDatabase(); const perSession = createPerSessionDataService(); @@ -15948,61 +17243,79 @@ suite('AgentService (node dispatcher)', () => { }); })); - test('drains published passive metadata updates before closing the central catalog', async () => { - const catalogDatabase = new TestAgentHostOrchestratorDatabase(); - const svc = disposables.add(createTestAgentService( - new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService(), - undefined, undefined, undefined, undefined, undefined, [], undefined, undefined, catalogDatabase, - )); - registerTestAgentProvider(svc, copilotAgent); - const session = await svc.createSession({ provider: 'copilot' }); - await svc.whenCatalogReconciliationIdle(); - const sessionKey = session.toString(); - const stateManager = getStateManager(svc); - stateManager.prepareSessionSummariesForListing([stateManager.getSessionSummary(sessionKey)!]); - stateManager.removeSession(sessionKey); - const internals = svc as unknown as { - _catalogSyncService: { - runExclusive(session: URI, operation: () => Promise): Promise; + for (const storageFails of [false, true]) { + test(`drains published passive metadata updates before closing the central catalog${storageFails ? ' when global storage fails' : ''}`, async () => { + const catalogDatabase = new TestAgentHostOrchestratorDatabase(); + const svc = disposables.add(createTestAgentService( + new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService(), + undefined, undefined, undefined, undefined, undefined, [], undefined, undefined, catalogDatabase, + )); + registerTestAgentProvider(svc, copilotAgent); + const session = await svc.createSession({ provider: 'copilot' }); + await svc.whenCatalogReconciliationIdle(); + const sessionKey = session.toString(); + const stateManager = getStateManager(svc); + stateManager.prepareSessionSummariesForListing([stateManager.getSessionSummary(sessionKey)!]); + stateManager.removeSession(sessionKey); + const internals = svc as unknown as { + _storageService: IAgentHostStorageService; + _catalogSyncService: { + runExclusive(session: URI, operation: () => Promise): Promise; + }; }; - }; - const blockerStarted = new DeferredPromise(); - const releaseBlocker = new DeferredPromise(); - const blocker = internals._catalogSyncService.runExclusive(session, async () => { - blockerStarted.complete(); - await releaseBlocker.p; - }); - await blockerStarted.p; - const changed = Event.toPromise(Event.filter(svc.onDidNotification, notification => notification.type === 'root/sessionSummaryChanged')); - svc.dispatchAction(sessionKey, { type: ActionType.SessionIsArchivedChanged, isArchived: true }, 'test-client', 1, AgentHostClientType.EditorWindow); - await changed; - const isRead = (stateManager.getSurfacedSessionSummary(sessionKey)!.status & SessionStatus.IsRead) === 0; - const readChanged = Event.toPromise(Event.filter(svc.onDidNotification, notification => notification.type === 'root/sessionSummaryChanged')); - svc.dispatchAction(sessionKey, { type: ActionType.SessionIsReadChanged, isRead }, 'test-client', 2, AgentHostClientType.EditorWindow); - await readChanged; - - let closed: AgentHostCatalogData | undefined; - catalogDatabase.close = async () => { - closed = catalogDataOf(await catalogDatabase.getSessionV2(sessionKey)); - }; - let shutdownComplete = false; - const shutdown = svc.shutdown().then(() => { shutdownComplete = true; }); - await timeout(0); - const shutdownBeforeRelease = shutdownComplete; - releaseBlocker.complete(); - await Promise.all([blocker, shutdown]); - await svc.whenCatalogReconciliationIdle(); + const blockerStarted = new DeferredPromise(); + const releaseBlocker = new DeferredPromise(); + const blocker = internals._catalogSyncService.runExclusive(session, async () => { + blockerStarted.complete(); + await releaseBlocker.p; + }); + await blockerStarted.p; + const changed = Event.toPromise(Event.filter(svc.onDidNotification, notification => notification.type === 'root/sessionSummaryChanged')); + svc.dispatchAction(sessionKey, { type: ActionType.SessionIsArchivedChanged, isArchived: true }, 'test-client', 1, AgentHostClientType.EditorWindow); + await changed; + const isRead = (stateManager.getSurfacedSessionSummary(sessionKey)!.status & SessionStatus.IsRead) === 0; + const readChanged = Event.toPromise(Event.filter(svc.onDidNotification, notification => notification.type === 'root/sessionSummaryChanged')); + svc.dispatchAction(sessionKey, { type: ActionType.SessionIsReadChanged, isRead }, 'test-client', 2, AgentHostClientType.EditorWindow); + await readChanged; + + let closed: AgentHostCatalogData | undefined; + catalogDatabase.close = async () => { + closed = catalogDataOf(await catalogDatabase.getSessionV2(sessionKey)); + }; + let shutdownComplete = false; + if (storageFails) { + internals._storageService.whenIdle = async () => { throw new Error('global storage unavailable'); }; + } + const shutdown = svc.shutdown().then( + () => { shutdownComplete = true; }, + error => { + shutdownComplete = true; + return error instanceof Error ? error.message : String(error); + }, + ); + await timeout(0); + const shutdownBeforeRelease = shutdownComplete; + releaseBlocker.complete(); + const [, shutdownError] = await Promise.all([blocker, shutdown]); + if (storageFails) { + await assert.rejects(svc.whenCatalogReconciliationIdle(), /global storage unavailable/); + } else { + await svc.whenCatalogReconciliationIdle(); + } - assert.deepStrictEqual({ - shutdownBeforeRelease, - archivedAtClose: closed?.isArchived, - readAtClose: closed?.isRead, - }, { - shutdownBeforeRelease: false, - archivedAtClose: true, - readAtClose: isRead, + assert.deepStrictEqual({ + shutdownBeforeRelease, + archivedAtClose: closed?.isArchived, + readAtClose: closed?.isRead, + shutdownError, + }, { + shutdownBeforeRelease: false, + archivedAtClose: true, + readAtClose: isRead, + shutdownError: storageFails ? 'global storage unavailable' : undefined, + }); }); - }); + } test('shuts down all providers', async () => { let copilotShutdown = false; @@ -20921,8 +22234,8 @@ suite('AgentService (node dispatcher)', () => { } const agent = disposables.add(new MultiChatAgent('copilotcli')); registerTestAgentProvider(localService, agent); - const broken = await localService.createSession({ provider: 'copilotcli' }); - const healthy = await localService.createSession({ provider: 'copilotcli' }); + const broken = await localService.createSession({ provider: 'copilotcli', session: AgentSession.uri('copilotcli', generateUuid()) }); + const healthy = await localService.createSession({ provider: 'copilotcli', session: AgentSession.uri('copilotcli', generateUuid()) }); const selectedId = '653a2e4f-5354-46ae-9a96-30c1b2cf4ce1'; const peerStore = new AgentHostPeerChatStore(database, sessionData, logService); for (const parent of [broken, healthy]) { @@ -21003,7 +22316,7 @@ suite('AgentService (node dispatcher)', () => { } const agent = disposables.add(new MultiChatAgent('copilotcli')); registerTestAgentProvider(localService, agent); - const parent = await localService.createSession({ provider: 'copilotcli' }); + const parent = await localService.createSession({ provider: 'copilotcli', session: AgentSession.uri('copilotcli', generateUuid()) }); const selectedId = '653a2e4f-5354-46ae-9a96-30c1b2cf4ce1'; const otherId = 'c805f176-d9f7-4a24-a332-feac66843088'; const selected = URI.parse(buildChatUri(parent, selectedId)); @@ -23023,7 +24336,7 @@ suite('AgentService (node dispatcher)', () => { stateAfterStaleResolution, recreatedPeerState: !!getStateManager(localService).getChatState(peerUri.toString()), }, { - materializeCalls: 2, + materializeCalls: 3, stateAfterStaleResolution: undefined, recreatedPeerState: true, }); @@ -23686,6 +24999,30 @@ suite('AgentService (node dispatcher)', () => { }); suite('rename server tools', () => { + function createRenameTestAgentService(sessionDataService: ISessionDataService, orchestratorDatabase?: IAgentHostDatabase): AgentService { + return createTestAgentService( + new NullLogService(), + fileService, + sessionDataService, + { _serviceBrand: undefined } as IProductService, + createNoopGitService(), + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + orchestratorDatabase, + undefined, + undefined, + undefined, + undefined, + 'activeAgent', + ); + } + test('rename_chat replaces live and persisted default and peer chat titles', async () => { class RecordingTitleDatabase extends TestSessionDatabase { readonly finalRenamePersisted = new DeferredPromise(); @@ -23716,22 +25053,7 @@ suite('AgentService (node dispatcher)', () => { const db = new RecordingTitleDatabase(); const catalogDatabase = disposables.add(new AgentHostDatabase(':memory:')); - const localService = disposables.add(createTestAgentService( - new NullLogService(), - fileService, - createSessionDataService(db), - { _serviceBrand: undefined } as IProductService, - createNoopGitService(), - undefined, - undefined, - undefined, - undefined, - undefined, - [], - undefined, - undefined, - catalogDatabase, - )); + const localService = disposables.add(createRenameTestAgentService(createSessionDataService(db), catalogDatabase)); const agent = disposables.add(new ServerToolAgent('copilot')); registerTestAgentProvider(localService, agent); const session = await localService.createSession({ provider: 'copilot' }); @@ -23848,7 +25170,7 @@ suite('AgentService (node dispatcher)', () => { } const db = new FailingTitleDatabase(); - const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const localService = disposables.add(createRenameTestAgentService(createSessionDataService(db))); const agent = disposables.add(new ServerToolAgent('copilot')); registerTestAgentProvider(localService, agent); const session = await localService.createSession({ provider: 'copilot' }); @@ -25261,7 +26583,7 @@ suite('AgentService (node dispatcher)', () => { const sessionDb = disposables.add(await SessionDatabase.open(':memory:')); const sessionDataService = createSessionDataService(sessionDb); const localAgent = new MockAgent('codex'); - const model = { id: 'codex-model:openai:gpt-5.6-sol' }; + const model = { id: '@provider=openai:gpt-5.6-sol' }; localAgent.sessionMetadataOverrides = { model } as typeof localAgent.sessionMetadataOverrides; disposables.add(toDisposable(() => localAgent.dispose())); const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); @@ -25279,10 +26601,16 @@ suite('AgentService (node dispatcher)', () => { await localService.restoreSession(session); - assert.deepStrictEqual(getStateManager(localService).getDefaultChatState(session.toString())?.draft, { - text: 'unsent text', - origin: { kind: MessageKind.User }, - model, + assert.deepStrictEqual({ + draft: getStateManager(localService).getDefaultChatState(session.toString())?.draft, + sessionModel: readCodexSessionModel(getStateManager(localService).getSessionSummary(session.toString())), + }, { + draft: { + text: 'unsent text', + origin: { kind: MessageKind.User }, + model, + }, + sessionModel: model, }); }); diff --git a/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts b/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts index 5acb716ecfce7c..8c6cf176f9cb8d 100644 --- a/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts +++ b/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts @@ -39,7 +39,7 @@ import { AgentHostClientConnectionService, IAgentHostClientConnectionService } f import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { IAgentHostStartupPerformance } from '../../node/agentHostStartupPerformance.js'; import { IAgentHostProviderService } from '../../node/agentHostProviderService.js'; -import { AgentHostSessionTitleController, IAgentHostSessionTitleController } from '../../node/agentHostSessionTitleController.js'; +import { AgentHostSessionTitleController, IAgentHostSessionTitleController, type AutomaticTitleGenerationStrategy } from '../../node/agentHostSessionTitleController.js'; import { AgentHostLocalTurns, IAgentHostLocalTurns } from '../../node/agentHostLocalTurns.js'; import { AgentHostLocalCommands, IAgentHostLocalCommands } from '../../node/localCommands/localChatCommand.js'; import { IAgentHostGitHubService } from '../../node/agentHostGitHubService.js'; @@ -155,6 +155,7 @@ export function createTestAgentService( sessionReleaseRetryMs?: number, catalogReconciliationOptions?: IAgentHostCatalogReconciliationOptions, startupPerformance?: IAgentHostStartupPerformance, + initialTitleGenerationStrategy: AutomaticTitleGenerationStrategy = 'utility', ): AgentService { const effectiveFileMonitorService = fileMonitorService ?? new AgentHostFileMonitorService(fileService, logService); const clientConnectionService = new AgentHostClientConnectionService(); @@ -233,6 +234,7 @@ export function createTestAgentService( getGitHubHost: () => foundation.gitHubEndpointService.getEnterpriseHost() ?? 'github.com', gitHubService, copilotApiService: effectiveCopilotApiService, + getInitialTitleGenerationStrategy: () => initialTitleGenerationStrategy, }))); const localTurns = new AgentHostLocalTurns(sessionDataService, logService); services.set(IAgentHostLocalTurns, localTurns); diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 98392482c7eee1..abf69b82e1da10 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -26,6 +26,7 @@ import { AgentSession, AgentSignal, IAgent, resolveAgentHostInstructions, resolv import { getTelemetryChatSessionId } from '../../common/agentTelemetryCorrelation.js'; import { buildDefaultChangesetCatalog } from '../../common/changesetUri.js'; import { readToolCallMeta } from '../../common/meta/agentToolCallMeta.js'; +import { readCodexSessionModel, withCodexSessionModel } from '../../common/meta/codexSessionModel.js'; import { toAgentMergeMessageMeta } from '../../common/meta/agentMergeMessageMeta.js'; import { withEphemeralSessionMeta } from '../../common/meta/agentEphemeralSessionMeta.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; @@ -137,6 +138,7 @@ class NoopGitStateService implements IAgentHostGitStateService { async refreshSessionGitState(_sessionKey: string, _workingDirectory?: URI): Promise { } getMaterializedWorktreeMeta(_sessionKey: string, _branchName: string): undefined { return undefined; } + async setFolderGitState(): Promise { } async resolveSessionBaseBranchName(_sessionKey: string): Promise { return undefined; } async setSessionGitHubState(_sessionKey: string, _state: ISessionGitHubState): Promise { } async recordSessionMerge(_sessionKey: string, _commit: string): Promise { } @@ -243,8 +245,13 @@ function createTestSideEffects( ); services.set(ISessionWorkspaceConversionService, { _serviceBrand: undefined, - requestSessionWorkspaceUpdate: () => { }, + supportsChatIsolation: () => false, + canIsolateChat: () => false, + requestChatIsolation: () => { }, + restoreChatIsolation: async () => { }, + requestSessionWorkspaceUpdate: () => true, isPending: () => false, + isConversionTurn: () => false, cancel: () => { }, updateSessionWorkspace: async () => { }, }); @@ -3777,6 +3784,30 @@ suite('AgentSideEffects', () => { // ---- handleAction: chat/turnStarted model selection -------------------- suite('handleAction — chat/turnStarted model selection', () => { + function createCodexTurnHarness(meta?: Record) { + const codexAgent = new MockAgent('codex'); + disposables.add(toDisposable(() => codexAgent.dispose())); + const session = AgentSession.uri('codex', 'model-session'); + const defaultChat = buildDefaultChatUri(session); + stateManager.createSession({ + resource: session.toString(), + provider: 'codex', + title: 'Codex model session', + status: SessionStatus.Idle, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + _meta: meta, + }); + stateManager.dispatchServerAction(session.toString(), { type: ActionType.SessionReady }); + const agents = observableValue('codexAgents', [codexAgent]); + const effects = createTestSideEffects(disposables, stateManager, { + getAgent: () => codexAgent, + agents, + sessionDataService: createNullSessionDataService(), + hostLaunchKind: AgentHostLaunchKind.VSCodeMainProcess, + }, undefined, disposables.add(new AgentHostTelemetryService(telemetryService))); + return { codexAgent, defaultChat, effects, session }; + } test('calls changeModel on the agent before sending the message', async () => { setupSession(); @@ -3868,6 +3899,75 @@ suite('AgentSideEffects', () => { chat: call.chat?.toString(), })), [{ session: sessionUri.toString(), model: { id: 'gpt-5' }, chat: chatChannel }]); }); + + test('stamps a successful default-chat Codex model selection before provider send', async () => { + const { codexAgent, defaultChat, effects, session } = createCodexTurnHarness(); + const model = { id: '@provider=openai:gpt-5.6-sol' }; + codexAgent.chatModel = model; + let modelAtSend: string | undefined; + const sent = new DeferredPromise(); + codexAgent.sendMessage = async () => { + modelAtSend = readCodexSessionModel(stateManager.getSessionState(session.toString()))?.id; + sent.complete(); + }; + const action = { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', + message: { text: 'hello', origin: { kind: MessageKind.User }, model }, + } as const; + stateManager.dispatchServerAction(defaultChat, action); + effects.handleAction(defaultChat, action); + await sent.p; + + assert.deepStrictEqual({ + modelAtSend, + modelInState: readCodexSessionModel(stateManager.getSessionState(session.toString()))?.id, + }, { + modelAtSend: model.id, + modelInState: model.id, + }); + }); + + test('does not stamp a rejected Codex model selection', async () => { + const original = { id: '@provider=openai:gpt-5.6-sol' }; + const { codexAgent, defaultChat, effects, session } = createCodexTurnHarness(withCodexSessionModel(undefined, original)); + codexAgent.chatModel = { id: '@provider=vscode-proxy:gpt-5.6-sol' }; + codexAgent.chats.changeModel = async () => { throw new Error('model selection failed'); }; + const failed = Event.toPromise(Event.filter(stateManager.onDidEmitEnvelope, envelope => envelope.action.type === ActionType.ChatError)); + const action = { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', + message: { text: 'hello', origin: { kind: MessageKind.User }, model: codexAgent.chatModel }, + } as const; + stateManager.dispatchServerAction(defaultChat, action); + effects.handleAction(defaultChat, action); + await failed; + + assert.strictEqual(readCodexSessionModel(stateManager.getSessionState(session.toString()))?.id, original.id); + }); + + test('does not overwrite the session model from a Codex peer chat', async () => { + const original = { id: '@provider=openai:gpt-5.6-sol' }; + const { codexAgent, effects, session } = createCodexTurnHarness(withCodexSessionModel(undefined, original)); + const peer = buildChatUri(session.toString(), 'peer-1'); + stateManager.addChat(session.toString(), peer, { title: 'Peer', origin: { kind: ChatOriginKind.User } }); + codexAgent.chatModel = { id: '@provider=vscode-proxy:gpt-5.6-sol' }; + const sent = new DeferredPromise(); + codexAgent.sendMessage = async () => { sent.complete(); }; + const action = { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', + message: { text: 'hello', origin: { kind: MessageKind.User }, model: codexAgent.chatModel }, + } as const; + stateManager.dispatchServerAction(peer, action); + effects.handleAction(peer, action); + await sent.p; + + assert.strictEqual(readCodexSessionModel(stateManager.getSessionState(session.toString()))?.id, original.id); + }); }); // ---- handleAction: chat/turnStarted agent selection -------------------- diff --git a/src/vs/platform/agentHost/test/node/byokResponsesTranslation.test.ts b/src/vs/platform/agentHost/test/node/byokResponsesTranslation.test.ts index 0564ba6b5d48f1..5a9f9ab5476180 100644 --- a/src/vs/platform/agentHost/test/node/byokResponsesTranslation.test.ts +++ b/src/vs/platform/agentHost/test/node/byokResponsesTranslation.test.ts @@ -100,6 +100,36 @@ suite('byokResponsesTranslation', () => { ]); }); + test('keeps array tool outputs with images as ordered content parts', () => { + // Shape the Copilot runtime sends when a tool result carries images for a vision-capable model. + const body: IResponsesRequest = { + model: 'm', + input: [ + { + type: 'function_call_output', call_id: 'call_1', output: [ + { type: 'input_text', text: 'caption A' }, + { type: 'input_image', detail: 'auto', image_url: 'data:image/png;base64,aW1hZ2VB' }, + { type: 'input_text', text: 'caption B' }, + { type: 'input_image', detail: 'auto', image_url: 'data:image/jpeg;base64,aW1hZ2VC' }, + ] + }, + { type: 'custom_tool_call_output', call_id: 'call_2', output: [{ type: 'input_text', text: 'a' }, { type: 'input_text', text: 'b' }] }, + ], + }; + + assert.deepStrictEqual(responsesRequestToBridge('acme', body).input, [ + { + type: 'function_call_output', callId: 'call_1', output: 'caption Acaption B', content: [ + { type: 'text', text: 'caption A' }, + { type: 'image', mimeType: 'image/png', data: 'aW1hZ2VB' }, + { type: 'text', text: 'caption B' }, + { type: 'image', mimeType: 'image/jpeg', data: 'aW1hZ2VC' }, + ] + }, + { type: 'custom_tool_call_output', callId: 'call_2', output: 'ab' }, + ]); + }); + suite('capBridgeTools', () => { const toolNames = (request: IByokLmChatRequest) => request.tools?.map(tool => tool.name); const requestWithTools = (count: number, input: IByokLmChatRequest['input'] = []): IByokLmChatRequest => ({ diff --git a/src/vs/platform/agentHost/test/node/chatContributions.test.ts b/src/vs/platform/agentHost/test/node/chatContributions.test.ts index d8a6145acde494..cf6d7f7dfc44e5 100644 --- a/src/vs/platform/agentHost/test/node/chatContributions.test.ts +++ b/src/vs/platform/agentHost/test/node/chatContributions.test.ts @@ -141,6 +141,7 @@ class RecordingGitStateService implements IAgentHostGitStateService { async refreshSessionGitState(_sessionKey: string, _workingDirectory?: URI): Promise { } getMaterializedWorktreeMeta(_sessionKey: string, _branchName: string): undefined { return undefined; } + async setFolderGitState(): Promise { } async resolveSessionBaseBranchName(_sessionKey: string): Promise { return undefined; } async setSessionGitHubState(_sessionKey: string, _state: ISessionGitHubState): Promise { } async recordSessionMerge(_sessionKey: string, _commit: string): Promise { } @@ -910,8 +911,13 @@ function createBuiltInContributions(disposables: ReturnType { }, + supportsChatIsolation: () => false, + canIsolateChat: () => false, + requestChatIsolation: () => { }, + restoreChatIsolation: async () => { }, + requestSessionWorkspaceUpdate: () => true, isPending: () => false, + isConversionTurn: () => false, cancel: () => { }, updateSessionWorkspace: async () => { observed?.push('sessionWorkspaceConversion'); }, }); @@ -982,9 +988,16 @@ function createQueueDrainContributions(disposables: ReturnType { }, + supportsChatIsolation: () => false, + canIsolateChat: () => false, + requestChatIsolation: () => { }, + restoreChatIsolation: async () => { }, + requestSessionWorkspaceUpdate: () => true, isPending: () => conversionPending, - cancel: () => { }, + isConversionTurn: () => false, + cancel: () => { + conversionPending = false; + }, updateSessionWorkspace: async () => { }, }); const mockAgent = new MockAgent(); @@ -1451,6 +1464,60 @@ suite('AgentHostChatContributions', () => { }); }); + test('workspace conversion release waits for the affected chat to complete before draining its queue', () => { + const queue = createQueueDrainContributions(disposables); + const peer = buildChatUri(queue.session, 'peer'); + queue.stateManager.addChat(queue.session, peer); + queue.setConversionPending(true); + for (const [chat, text] of [[queue.chat, 'main queued'], [peer, 'peer queued']]) { + const action = queuedMessage(text, text); + queue.stateManager.dispatchServerAction(chat, action); + queue.service.didApplyClientAction(appliedClientAction(chat, queue.session, action)); + } + const peerAdmission = queue.service.incomingRequest(incomingRequest(queue.session, peer)); + assert.deepStrictEqual({ + admitted: queue.admitted.length, peerAdmission: peerAdmission.kind, + queued: [queue.chat, peer].map(chat => queue.stateManager.getChatState(chat)?.queuedMessages?.map(message => message.message.text)), + }, { admitted: 0, peerAdmission: 'reject', queued: [['main queued'], ['peer queued']] }); + queue.setConversionPending(false); + const admittedOnRelease = queue.admitted.length; + queue.service.turnEnd({ session: queue.session, channel: queue.chat, turnId: 'continuation', reason: { kind: 'success' } }); + assert.deepStrictEqual({ + admittedOnRelease, + admitted: queue.admitted.map(turn => ({ chat: turn.channel, message: turn.message.text })), + peerQueue: queue.stateManager.getChatState(peer)?.queuedMessages?.map(message => message.message.text), + }, { + admittedOnRelease: 0, + admitted: [{ chat: queue.chat, message: 'main queued' }], + peerQueue: ['peer queued'], + }); + }); + + for (const kind of ['cancelled', 'error'] as const) { + test(`a ${kind} workspace-change turn retains queued work in every chat`, () => { + const queue = createQueueDrainContributions(disposables); + const peer = buildChatUri(queue.session, 'peer'); + queue.stateManager.addChat(queue.session, peer); + queue.setConversionPending(true); + for (const chat of [queue.chat, peer]) { + const action = queuedMessage(chat, 'queued task'); + queue.stateManager.dispatchServerAction(chat, action); + queue.service.didApplyClientAction(appliedClientAction(chat, queue.session, action)); + } + queue.service.turnEnd({ + session: queue.session, + channel: queue.chat, + turnId: 'workspace-change', + reason: kind === 'error' ? { kind, error: { errorType: 'test', message: 'failed' }, resumable: false } : { kind }, + }); + assert.deepStrictEqual({ + admitted: queue.admitted, + admission: queue.service.incomingRequest(incomingRequest(queue.session, queue.chat)).kind, + queued: [queue.chat, peer].map(chat => queue.stateManager.getChatState(chat)?.queuedMessages?.map(message => message.message.text)), + }, { admitted: [], admission: 'accept', queued: [['queued task'], ['queued task']] }); + }); + } + test('queue drain captures senders, handles pending actions, and honors reordering', () => { const queue = createQueueDrainContributions(disposables); queue.stateManager.dispatchServerAction(queue.chat, { diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index 785af526d921f5..7f247e186387d8 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -2607,6 +2607,19 @@ suite('ClaudeAgent', () => { }); }); + test('standard host sessions retain their address and an independent Claude backing ID', async () => { + const { agent } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const session = URI.parse('ahp-session:/new-claude'); + const result = await createSession(agent, { session, workingDirectories: [URI.file('/work')] }); + assert.deepStrictEqual({ + session: result.session.toString(), + provisional: result.provisional, + backingProvider: result.chat?.backingSession?.scheme, + separateBacking: result.sdkSessionId !== AgentSession.id(session), + }, { session: session.toString(), provisional: true, backingProvider: 'claude', separateBacking: true }); + }); + test('createChat({ fork }) forks at the anchor uuid, then materializes lazily on first sendMessage', async () => { // Fork translates turnId u1 → its last-assistant uuid a1 (INCLUSIVE), // returns non-provisional WITHOUT starting the Query; the first @@ -7214,10 +7227,10 @@ suite('ClaudeAgent (Phase 7 §3.4 — _handleCanUseTool)', () => { }, { result: { behavior: 'allow', updatedInput: input }, confirmation: { - displayName: 'Set Workspace', - invocationMessage: 'Continue this session in /workspace/app with changes isolated from the existing folder?', + displayName: 'Change Workspace', + invocationMessage: 'Change this chat\'s workspace to a new worktree of /workspace/app? Other chats keep their workspaces.', toolInput: undefined, - confirmationTitle: 'Continue in app?', + confirmationTitle: 'Change Workspace to app?', permissionKind: 'mcp', }, }); diff --git a/src/vs/platform/agentHost/test/node/codex/codexAccountTelemetry.test.ts b/src/vs/platform/agentHost/test/node/codex/codexAccountTelemetry.test.ts index 47ea0718cfc885..78d6a8fcba8b03 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexAccountTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexAccountTelemetry.test.ts @@ -18,6 +18,7 @@ suite('CodexAccountTelemetry', () => { const now = 1_000_000; const account: ICodexAccountState = { usageSource: 'openai', status: 'signedIn', authType: 'chatgpt', planType: 'plus' }; const rateLimit: ICodexAccountRateLimitInfo = { usedPercent: 42.4, windowDurationMins: 7 * 24 * 60, resetsAt: now / 1000 + 3600 }; + const fiveHourRateLimit: ICodexAccountRateLimitInfo = { usedPercent: 24.2, windowDurationMins: 5 * 60, resetsAt: now / 1000 + 1800 }; test('normalizes every generated plan value', () => { const expected = { @@ -48,14 +49,14 @@ suite('CodexAccountTelemetry', () => { { ...account, status: 'signedOut' }, ]; assert.deepStrictEqual(accounts.map(value => getCodexAccountTelemetryContext(value, rateLimit, now, now)), [ - ...accounts.slice(0, -1).map(() => ({ chatgptAccountState: 'unknown', chatgptWeeklyQuotaState: 'unavailable' })), - { chatgptAccountState: 'signedOut', chatgptWeeklyQuotaState: 'unavailable' }, + ...accounts.slice(0, -1).map(() => ({ chatgptAccountState: 'unknown', chatgptWeeklyQuotaState: 'unavailable', chatgptFiveHourQuotaState: 'unavailable' })), + { chatgptAccountState: 'signedOut', chatgptWeeklyQuotaState: 'unavailable', chatgptFiveHourQuotaState: 'unavailable' }, ]); }); test('preserves unknown plan and missing snapshot availability', () => { assert.deepStrictEqual(getCodexAccountTelemetryContext({ ...account, planType: undefined }, undefined, undefined, now), { - chatgptAccountState: 'signedIn', chatgptPlanTier: 'unknown', chatgptWeeklyQuotaState: 'missing', + chatgptAccountState: 'signedIn', chatgptPlanTier: 'unknown', chatgptWeeklyQuotaState: 'missing', chatgptFiveHourQuotaState: 'missing', }); }); @@ -65,6 +66,16 @@ suite('CodexAccountTelemetry', () => { samples.map((_, index) => ({ chatgptAccountState: 'signedIn', chatgptPlanTier: 'plus', chatgptWeeklyQuotaState: 'available', chatgptWeeklyUsedPercentBucket: Math.floor(index / 2) * 10, + chatgptFiveHourQuotaState: 'missing', + }))); + }); + + test('buckets all five-hour boundaries from the multi-window snapshot', () => { + const samples = [0, ...Array.from({ length: 10 }, (_, index) => [(index + 1) * 10 - 0.01, (index + 1) * 10]).flat()]; + assert.deepStrictEqual(samples.map(usedPercent => getCodexAccountTelemetryContext(account, rateLimit, now - 5 * 60 * 1000, now, [rateLimit, { ...fiveHourRateLimit, usedPercent }])), + samples.map((_, index) => ({ + chatgptAccountState: 'signedIn', chatgptPlanTier: 'plus', chatgptWeeklyQuotaState: 'available', chatgptWeeklyUsedPercentBucket: 40, + chatgptFiveHourQuotaState: 'available', chatgptFiveHourUsedPercentBucket: Math.floor(index / 2) * 10, }))); }); @@ -81,13 +92,28 @@ suite('CodexAccountTelemetry', () => { ...[NaN, Infinity, -Infinity, -0.01, 100.01].map(usedPercent => ({ rateLimit: { ...rateLimit, usedPercent }, observedAt: now, state: 'invalid' as const })), ]; assert.deepStrictEqual(cases.map(value => getCodexAccountTelemetryContext(account, value.rateLimit, value.observedAt, now)), - cases.map(value => ({ chatgptAccountState: 'signedIn', chatgptPlanTier: 'plus', chatgptWeeklyQuotaState: value.state }))); + cases.map(value => ({ chatgptAccountState: 'signedIn', chatgptPlanTier: 'plus', chatgptWeeklyQuotaState: value.state, chatgptFiveHourQuotaState: 'missing' }))); + }); + + test('distinguishes unusable five-hour snapshots without emitting a bucket', () => { + const cases: { rateLimits: readonly ICodexAccountRateLimitInfo[] | undefined; observedAt: number | undefined; state: ICodexAccountTelemetryContext['chatgptFiveHourQuotaState'] }[] = [ + { rateLimits: undefined, observedAt: now, state: 'missing' }, + { rateLimits: [rateLimit], observedAt: now, state: 'missing' }, + { rateLimits: [rateLimit, fiveHourRateLimit], observedAt: undefined, state: 'missing' }, + { rateLimits: [rateLimit, fiveHourRateLimit], observedAt: now - 5 * 60 * 1000 - 1, state: 'stale' }, + ...[now + 1, -1, NaN, Infinity, -Infinity].map(observedAt => ({ rateLimits: [rateLimit, fiveHourRateLimit], observedAt, state: 'invalid' as const })), + ...[now / 1000, now / 1000 - 1].map(resetsAt => ({ rateLimits: [rateLimit, { ...fiveHourRateLimit, resetsAt }], observedAt: now, state: 'expired' as const })), + ...[NaN, Infinity, -Infinity, 0, -1].map(resetsAt => ({ rateLimits: [rateLimit, { ...fiveHourRateLimit, resetsAt }], observedAt: now, state: 'invalid' as const })), + ...[NaN, Infinity, -Infinity, -0.01, 100.01].map(usedPercent => ({ rateLimits: [rateLimit, { ...fiveHourRateLimit, usedPercent }], observedAt: now, state: 'invalid' as const })), + ]; + assert.deepStrictEqual(cases.map(value => getCodexAccountTelemetryContext(account, rateLimit, value.observedAt, now, value.rateLimits).chatgptFiveHourQuotaState), + cases.map(value => value.state)); }); test('accepts an unknown reset and a reset after admission', () => { assert.deepStrictEqual([undefined, now / 1000 + 0.001].map(resetsAt => getCodexAccountTelemetryContext(account, { ...rateLimit, resetsAt }, now, now)), [ - { chatgptAccountState: 'signedIn', chatgptPlanTier: 'plus', chatgptWeeklyQuotaState: 'available', chatgptWeeklyUsedPercentBucket: 40 }, - { chatgptAccountState: 'signedIn', chatgptPlanTier: 'plus', chatgptWeeklyQuotaState: 'available', chatgptWeeklyUsedPercentBucket: 40 }, + { chatgptAccountState: 'signedIn', chatgptPlanTier: 'plus', chatgptWeeklyQuotaState: 'available', chatgptWeeklyUsedPercentBucket: 40, chatgptFiveHourQuotaState: 'missing' }, + { chatgptAccountState: 'signedIn', chatgptPlanTier: 'plus', chatgptWeeklyQuotaState: 'available', chatgptWeeklyUsedPercentBucket: 40, chatgptFiveHourQuotaState: 'missing' }, ]); }); @@ -98,7 +124,7 @@ suite('CodexAccountTelemetry', () => { sourceAccount.planType = 'free'; sourceRateLimit.usedPercent = 100; assert.deepStrictEqual({ snapshot, frozen: Object.isFrozen(snapshot) }, { - snapshot: { chatgptAccountState: 'signedIn', chatgptPlanTier: 'plus', chatgptWeeklyQuotaState: 'available', chatgptWeeklyUsedPercentBucket: 40 }, + snapshot: { chatgptAccountState: 'signedIn', chatgptPlanTier: 'plus', chatgptWeeklyQuotaState: 'available', chatgptWeeklyUsedPercentBucket: 40, chatgptFiveHourQuotaState: 'missing' }, frozen: true, }); }); diff --git a/src/vs/platform/agentHost/test/node/codex/codexChatDiscovery.test.ts b/src/vs/platform/agentHost/test/node/codex/codexChatDiscovery.test.ts index 43b11e2372fd2f..564c905eed5a6b 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexChatDiscovery.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexChatDiscovery.test.ts @@ -25,6 +25,7 @@ import { ITelemetryService, TelemetryLevel } from '../../../../telemetry/common/ import { NullTelemetryService } from '../../../../telemetry/common/telemetryUtils.js'; import { AgentChatMigrationDeferred, AgentSession, IAgentDiscoveredChat } from '../../../common/agent.js'; import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../../../common/agentHostCheckpointService.js'; +import { readCodexSessionModel } from '../../../common/meta/codexSessionModel.js'; import { buildNonPtyShellTerminalUri } from '../../../common/nonPtyShellTerminalUri.js'; import { IAgentHostOTelService } from '../../../common/otel/agentHostOTelService.js'; import { ISessionDataService } from '../../../common/sessionDataService.js'; @@ -56,6 +57,11 @@ const codexHome = URI.file('/codex-discovery/custom-home'); interface ITestCodexAgent { _activated: boolean; + _refreshAccount(): Promise<{ usageSource: string; status: string }>; + _refreshContinuationAccount(): Promise; + _sessions: { readonly size: number }; + _sessionIdByChatUri: { readonly size: number }; + _metadataStore: { hasKnownSession(session: URI): Promise }; _probeAccountAtStartup(): Promise; _restartChatDiscovery(): void; _emitCodexChats(): Promise; @@ -419,6 +425,52 @@ suite('Codex chat discovery', () => { } })); + test('on-demand continuation refresh reads account limits without scanning or publishing threads', () => runWithFakedTimers({}, async () => { + const { internal, client, downloader, events } = createHarness(disposables.add(new DisposableStore())); + downloader.resolvableWithoutDownload = true; + internal._activated = false; + let accountRefreshes = 0; + internal._refreshAccount = async () => { + accountRefreshes++; + return { usageSource: 'openai', status: 'signedOut' }; + }; + client.threads = [{ ...thread('passive'), modelProvider: 'openai', model: 'gpt' }]; + await internal._refreshContinuationAccount(); + assert.deepStrictEqual({ + accountRefreshes, listCalls: client.listCalls, runtimes: internal._sessions.size, + bindings: internal._sessionIdByChatUri.size, active: internal._activated, + reads: client.historyRequests, discovered: events.flat().length, + }, { accountRefreshes: 1, listCalls: 0, runtimes: 0, bindings: 0, active: false, reads: [], discovered: 0 }); + })); + + test('passive catalog publishes exact provider/model and invalidates when either changes', () => runWithFakedTimers({}, async () => { + const { agent, client, filesystem } = createHarness(disposables.add(new DisposableStore())); + client.threads = [{ ...thread('external'), modelProvider: 'openai', model: 'gpt' }]; + await agent.startChatDiscovery(); + const session = AgentSession.uri('codex', 'external'); + const chat = URI.parse(buildDefaultChatUri(session)); + const initial = await agent.getChatMetadata(chat, session); + client.threads = [{ ...thread('external'), modelProvider: 'openai', model: 'gpt-next' }]; + filesystem.changeHomeFile('session_index.jsonl'); + await timeout(6000); + const changedModel = await agent.getChatMetadata(chat, session); + client.threads = [{ ...thread('external'), modelProvider: 'vscode-proxy', model: 'gpt-next' }]; + filesystem.changeHomeFile('session_index.jsonl'); + await timeout(6000); + const changedProvider = await agent.getChatMetadata(chat, session); + assert.deepStrictEqual([ + initial?.model?.id, readCodexSessionModel(initial)?.id, + changedModel?.model?.id, readCodexSessionModel(changedModel)?.id, + changedProvider?.model?.id, readCodexSessionModel(changedProvider)?.id, + client.historyRequests, + ], [ + '@provider=openai:gpt', '@provider=openai:gpt', + '@provider=openai:gpt-next', '@provider=openai:gpt-next', + '@provider=vscode-proxy:gpt-next', '@provider=vscode-proxy:gpt-next', + [], + ]); + })); + test('rollout updates do not rescan the catalog while additions and deletions do', () => runWithFakedTimers({}, async () => { const store = disposables.add(new DisposableStore()); const { agent, client, files, events } = createHarness(store); diff --git a/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts b/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts index cb8be2710ba4ec..664db5b15c0a09 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts @@ -29,7 +29,7 @@ import { AgentSession, AgentWorkingDirectoryChangedError, type AgentSignal, type import { buildChatUri, buildDefaultChatUri, chatStorageUri, MessageAttachmentKind, ResponsePartKind, SessionStatus } from '../../../common/state/sessionState.js'; import { ActionType } from '../../../common/state/sessionActions.js'; import { CustomizationType, McpServerStatus } from '../../../common/state/protocol/channels-session/state.js'; -import type { IAgentServerToolHost } from '../../../common/agentServerTools.js'; +import type { IAgentServerToolDefinition, IAgentServerToolHost } from '../../../common/agentServerTools.js'; import { IAgentPluginManager } from '../../../common/agentPluginManager.js'; import { ISessionDataService, type ISessionDatabase } from '../../../common/sessionDataService.js'; import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../../../common/agentHostCheckpointService.js'; @@ -87,6 +87,7 @@ interface ITestPeer { /** Extra disposables (e.g. request-handler registrations from `connectPeer`) released alongside the peer. */ readonly disposables: DisposableStore; push(message: object): void; + exit(): void; dispose(): void; } @@ -116,6 +117,7 @@ function createTestPeer(): ITestPeer { outbound, disposables, push: message => stdout.write(JSON.stringify(message) + '\n'), + exit: () => onExit.fire({ code: 1, signal: null }), dispose: () => { disposables.dispose(); onExit.dispose(); @@ -170,8 +172,7 @@ interface ICreateAgentOptions { } /** - * Per-session durable storage, keyed by session URI exactly like the real - * service. Restore tests depend on this: a runtime's metadata overlay (its + * Per-URI isolated storage for the fixture. A runtime's metadata overlay (its * codex thread id) is stored under the session URI it was persisted with, so a * blob that names the wrong id must not accidentally find someone else's * overlay. @@ -223,7 +224,7 @@ async function createAgent(disposables: Pick, options: I hostPluginsPath: URI.file('/plugins/.host'), syncCustomizations: async (_clientId, customizations) => customizations.map(customization => ({ customization })), }); - instantiationService.stub(ISessionDataService, options.sessionStore?.service ?? { _serviceBrand: undefined }); + instantiationService.stub(ISessionDataService, options.sessionStore?.service ?? createTestSessionStore().service); instantiationService.stub(ICopilotApiService, { _serviceBrand: undefined, models: async () => models }); instantiationService.stub(ICodexProxyService, { _serviceBrand: undefined }); instantiationService.stub(IAgentConfigurationService, configurationService); @@ -2089,13 +2090,16 @@ suite('CodexAgent createChat', () => { const entry = agent['_sessions'].get('session-prewarm')!; await entry.materializePromise; + const dispatchedProviders: string[] = []; const sending = agent.chats.sendMessage(chat, 'hello', [folder], undefined, 'turn-1', undefined, { configurationResource: sessionUri, resource: chat, hostInstructions: ['Rename with exact casing'], + reportCodexModelProvider: provider => dispatchedProviders.push(provider), }); const turn = await readNextRequest(peer.outbound); assert.strictEqual(turn.method, 'turn/start'); + assert.deepStrictEqual(dispatchedProviders, ['copilot']); assert.strictEqual(turn.params.threadId, 'prewarmed-thread'); assert.deepStrictEqual(turn.params.input, [{ type: 'text', text: 'hello', text_elements: [] }]); assert.deepStrictEqual(turn.params.additionalContext?.['vscode.agentHost'], { kind: 'application', value: 'Rename with exact casing' }); @@ -2182,7 +2186,8 @@ suite('CodexAgent workspace conversion', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); async function createWorkspaceHarness(id = 'workspace-less', existing?: { readonly agent: CodexAgent; readonly peer: ITestPeer }) { - const agent = existing?.agent ?? await createAgent(disposables, { sessionStore: createTestSessionStore() }); + const sessionStore = createTestSessionStore(); + const agent = existing?.agent ?? await createAgent(disposables, { sessionStore }); const peer = existing?.peer ?? disposables.add(createTestPeer()); if (!existing) { connectPeer(agent, peer); @@ -2225,9 +2230,120 @@ suite('CodexAgent workspace conversion', () => { method: 'thread/settings/updated', params: { threadId: notificationThreadId, threadSettings: { cwd: directory.fsPath } }, }); - return { agent, peer, session, chat, scratch, folder, threadId, entry, notifyDirectory }; + return { agent, peer, session, chat, scratch, folder, threadId, entry, notifyDirectory, sessionStore }; + } + + async function addIsolationPeer(harness: Awaited>, id: string) { + const { agent, peer, session, scratch } = harness; + const chat = URI.parse(buildChatUri(session, id)); + const creating = agent.chats.createChat(chat, session, { workingDirectories: [scratch], model: { id: COPILOT_TEST_MODEL } }); + const start = await readNextRequest(peer.outbound); + peer.push({ id: start.id, result: { thread: { id, cwd: scratch.fsPath } } }); + const result = await creating; + const entry = agent['_sessions'].get(id)!; + entry.firstTurnSent = true; + entry.codexTurnIdByHostTurnId.set(`host-${id}`, `app-${id}`); + return { chat, entry, result }; } + function confirmIsolationUpdate(harness: Awaited>, request: ITestWireRequest) { + harness.peer.push({ id: request.id, result: {} }); + harness.notifyDirectory(URI.file(request.params.cwd!), request.params.threadId); + } + + test('chat isolation changes only the addressed thread even when a sibling is active', async () => { + const harness = await createWorkspaceHarness(); + const { agent, peer, session, folder, scratch, entry } = harness; + const target = await addIsolationPeer(harness, 'isolating-peer'); + entry.currentTurnId = 'busy-main'; + const history = [...target.entry.codexTurnIdByHostTurnId]; + const changing = agent.setChatWorkingDirectory(target.chat, { configurationResource: session, resource: target.chat }, folder); + const request = await readNextRequest(peer.outbound); + confirmIsolationUpdate(harness, request); + await changing; + assert.deepStrictEqual({ + request: request.params, + roots: [entry.workingDirectory?.fsPath, target.entry.workingDirectory?.fsPath], + history: [...target.entry.codexTurnIdByHostTurnId], + persisted: await Promise.all([entry, target.entry].map(async runtime => (await agent['_metadataStore'].read(runtime.sessionUri)).cwd?.fsPath)), + }, { + request: { threadId: target.entry.threadId, cwd: folder.fsPath }, + roots: [scratch.fsPath, folder.fsPath], history, persisted: [scratch.fsPath, folder.fsPath], + }); + }); + + for (const disconnect of [false, true]) { + test(`chat isolation signals quarantine for ${disconnect ? 'disconnection' : 'timeout'} after an update request`, async () => { + const harness = await createWorkspaceHarness(); + const { agent, peer, session, chat, folder } = harness; + await runWithFakedTimers({ useFakeTimers: true }, async () => { + const changing = agent.setChatWorkingDirectory(chat, { configurationResource: session, resource: session }, folder); + const rejected = assert.rejects(changing, error => error instanceof AgentWorkingDirectoryChangedError && error.workingDirectory.toString() === folder.toString()); + const request = await readNextRequest(peer.outbound); + peer.push({ id: request.id, result: {} }); + if (disconnect) { + const connection = agent['_connection']; + assert.ok(connection.kind === 'ready'); + agent['_handleConnectionLost'](connection, agent['_connectionGeneration']); + } + await rejected; + }); + }); + } + + test('chat isolation rethrows a definitive Codex rejection instead of signaling quarantine', async () => { + const harness = await createWorkspaceHarness(); + const { agent, peer, session, chat, folder, scratch, entry } = harness; + const changing = agent.setChatWorkingDirectory(chat, { configurationResource: session, resource: session }, folder); + const rejected = assert.rejects(changing, error => !(error instanceof AgentWorkingDirectoryChangedError) && /update rejected/.test(String(error))); + const request = await readNextRequest(peer.outbound); + peer.push({ id: request.id, error: { code: -32602, message: 'update rejected' } }); + await rejected; + assert.strictEqual(entry.workingDirectory?.fsPath, scratch.fsPath); + }); + + test('chat isolation signals quarantine when the Codex process exits before answering the update', async () => { + const harness = await createWorkspaceHarness(); + const { agent, peer, session, chat, folder } = harness; + const changing = agent.setChatWorkingDirectory(chat, { configurationResource: session, resource: session }, folder); + const rejected = assert.rejects(changing, error => error instanceof AgentWorkingDirectoryChangedError && error.workingDirectory.toString() === folder.toString()); + await readNextRequest(peer.outbound); + peer.exit(); + await rejected; + }); + + test('chat isolation signals quarantine when durable metadata cannot be committed', async () => { + const harness = await createWorkspaceHarness(); + const { agent, peer, session, folder, scratch, entry } = harness; + const target = await addIsolationPeer(harness, 'failing-metadata-peer'); + harness.sessionStore.databaseFor(target.entry.sessionUri).setMetadata = async () => { throw new Error('metadata unavailable'); }; + const changing = agent.setChatWorkingDirectory(target.chat, { configurationResource: session, resource: target.chat }, folder); + const rejected = assert.rejects(changing, error => error instanceof AgentWorkingDirectoryChangedError && error.workingDirectory.toString() === folder.toString()); + confirmIsolationUpdate(harness, await readNextRequest(peer.outbound)); + await rejected; + assert.deepStrictEqual({ + main: entry.workingDirectory?.fsPath, + peer: target.entry.workingDirectory?.fsPath, + mainMetadata: (await agent['_metadataStore'].read(session)).cwd?.fsPath, + }, { main: scratch.fsPath, peer: folder.fsPath, mainMetadata: scratch.fsPath }); + }); + + test('native worker root routing cannot execute a topLevelChatOnly server tool', async () => { + const { agent, entry } = await createWorkspaceHarness(); + let calls = 0; + const definition: IAgentServerToolDefinition = { name: 'top_level_only', topLevelChatOnly: true, inputSchema: { type: 'object' } }; + agent.setServerToolHost({ + ...createRecordingServerToolHost([]), + definitions: [definition], + toolNames: [definition.name], + getDefinitionsForSession: () => [definition], + executeTool: () => { calls++; return ''; }, + }); + agent['_sessionIdByThreadId'].set('native-child', entry.sessionId); + const response = await agent['_handleDynamicToolCallRpc']({ threadId: 'native-child', turnId: 'child-turn', callId: 'child-call', tool: definition.name, namespace: null, arguments: {} }); + assert.deepStrictEqual({ calls, success: response.result?.success }, { calls: 0, success: false }); + }); + for (const notificationFirst of [false, true]) { test(`preserves the thread and history when settings arrive ${notificationFirst ? 'before' : 'after'} acknowledgement`, async () => { const { agent, peer, session, chat, scratch, folder, threadId, entry, notifyDirectory } = await createWorkspaceHarness(); @@ -4345,6 +4461,72 @@ suite('CodexAgent chat backing durability', () => { } }); + for (const sdkResolvableWithoutDownload of [false, true]) { + test(`materializeChat without a saved model does not start Codex (SDK local: ${sdkResolvableWithoutDownload})`, async () => { + const agent = await createAgent(disposables, { sdkResolvableWithoutDownload, sessionStore: createTestSessionStore() }); + const session = AgentSession.uri('codex', 'lazy-model-restore'); + const chat = URI.parse(buildDefaultChatUri(session)); + + await agent.materializeChat(chat, { configurationResource: session, resource: chat }, undefined); + + assert.deepStrictEqual({ + connection: agent['_connection'].kind, + needsResume: agent['_sessions'].get('lazy-model-restore')?.needsResume, + }, { + connection: 'idle', + needsResume: true, + }); + }); + } + + test('materializeChat reuses a discovered native model without another app-server request', async () => { + const agent = await createAgent(disposables, { sdkResolvableWithoutDownload: true, sessionStore: createTestSessionStore() }); + const peer = disposables.add(createTestPeer()); + connect(agent, peer); + const session = AgentSession.uri('codex', 'discovered-model'); + const chat = URI.parse(buildDefaultChatUri(session)); + const listing = agent['_listCodexChats']('discovery'); + const list = await readNextRequest(peer.outbound); + assert.strictEqual(list.method, 'thread/list'); + peer.push({ id: list.id, result: { data: [{ id: 'discovered-model', cwd: '/repo/discovered', modelProvider: 'openai', model: 'gpt-test' }], nextCursor: null } }); + await listing; + + await agent.materializeChat(chat, { configurationResource: session, resource: chat }, undefined); + + assert.deepStrictEqual({ + model: agent['_sessions'].get('discovered-model')?.model?.id, + pendingAppServerBytes: peer.outbound.readableLength, + }, { + model: '@provider=openai:gpt-test', + pendingAppServerBytes: 0, + }); + }); + + test('restores an external thread using its persisted provider and model before the default', async () => { + const agent = await createAgent(disposables, { sdkResolvableWithoutDownload: true, sessionStore: createTestSessionStore() }); + const peer = disposables.add(createTestPeer()); + connect(agent, peer); + const session = AgentSession.uri('codex', 'external-model'); + const chat = URI.parse(buildDefaultChatUri(session)); + const context = { configurationResource: session, resource: chat }; + // The host reads authoritative metadata before materializing the backing. + const restoring = agent.getChatMetadata(chat, context, undefined, { activation: 'restore' }); + const read = await readNextRequest(peer.outbound); + assert.strictEqual(read.method, 'thread/read'); + peer.push({ id: read.id, result: { thread: { id: 'external-model', cwd: '/repo/external', modelProvider: 'openai', model: 'gpt-test' } } }); + const metadata = await restoring; + await agent.materializeChat(chat, context, undefined); + assert.deepStrictEqual({ + metadataModel: metadata?.model?.id, + runtimeModel: agent['_sessions'].get('external-model')?.model?.id, + pendingAppServerBytes: peer.outbound.readableLength, + }, { + metadataModel: '@provider=openai:gpt-test', + runtimeModel: '@provider=openai:gpt-test', + pendingAppServerBytes: 0, + }); + }); + test('materializeChat rolls back a newly restored runtime when server-tool advertisement fails', async () => { const agent = await createAgent(disposables); const session = AgentSession.uri('codex', 'restore-fail-advertise'); @@ -4352,7 +4534,7 @@ suite('CodexAgent chat backing durability', () => { agent.setServerToolHost(createThrowingAdvertiseServerToolHost('restore advertise boom')); await assert.rejects( - agent.materializeChat(chat, { configurationResource: session, resource: chat }, JSON.stringify({ sessionId: 'restored-backing' })), + agent.materializeChat(chat, { configurationResource: session, resource: chat }, JSON.stringify({ sessionId: 'restored-backing', model: { id: 'gpt-test' } })), /restore advertise boom/, ); @@ -4367,9 +4549,61 @@ suite('CodexAgent chat backing durability', () => { }); }); - test('the materialize receipt re-keys the chat backing onto the runtime, so a restored session stays addressable', async () => { + test('database read failures do not block normal create or restore', async () => { + const sessionStore = createTestSessionStore(); + sessionStore.service.tryOpenDatabase = async () => { throw new Error('database unavailable'); }; + const agent = await createAgent(disposables, { sessionStore }); + const session = AgentSession.uri('codex', 'database-unavailable'); + const chat = URI.parse(buildDefaultChatUri(session)); + const folder = URI.file('/repo/source'); + const created = await createSessionBackedChat(agent, chat, { configurationResource: session, resource: chat }, { + workingDirectories: [folder], model: { id: COPILOT_TEST_MODEL }, + }); + const restoredChat = URI.parse(buildChatUri(session, 'restored')); + await agent.materializeChat(restoredChat, { configurationResource: session, resource: restoredChat }, JSON.stringify({ sessionId: 'restored-backing' })); + const restored = agent['_sessions'].get('restored-backing'); + assert.deepStrictEqual({ + createdDirectory: created.resolvedWorkingDirectory?.fsPath, + restoredThread: restored?.threadId, + restoredDirectory: restored?.workingDirectory, + restoredChat: restored?.chatChannel?.toString(), + }, { + createdDirectory: folder.fsPath, + restoredThread: 'restored-backing', + restoredDirectory: undefined, + restoredChat: restoredChat.toString(), + }); + }); + + test('database read failures retain native thread metadata', async () => { + const sessionStore = createTestSessionStore(); + sessionStore.service.tryOpenDatabase = async () => { throw new Error('database unavailable'); }; + const agent = await createAgent(disposables, { sessionStore }); + const peer = disposables.add(createTestPeer()); + connect(agent, peer); + const session = AgentSession.uri('codex', 'native-metadata'); + const chat = URI.parse(buildDefaultChatUri(session)); + const folder = URI.file('/repo/native'); + const reading = agent.getChatMetadata(chat, { configurationResource: session, resource: chat }, undefined, { activation: 'restore' }); + const request = await readNextRequest(peer.outbound); + peer.push({ id: request.id, result: { thread: { id: 'native-thread', cwd: folder.fsPath, name: 'Native title', turns: [] } } }); + const metadata = await reading; + assert.deepStrictEqual({ + request: { method: request.method, threadId: request.params.threadId }, + summary: metadata?.summary, + roots: metadata?.workingDirectories?.map(directory => directory.fsPath), + restoredDirectory: agent['_sessions'].get('native-metadata')?.workingDirectory?.fsPath, + }, { + request: { method: 'thread/read', threadId: 'native-metadata' }, + summary: 'Native title', + roots: [folder.fsPath], + restoredDirectory: folder.fsPath, + }); + }); + + ['codex', 'ahp-session'].forEach(scheme => test(`the materialize receipt keeps ${scheme} sessions addressable after a cold provider restart`, async () => { const sessionStore = createTestSessionStore(); - const session = AgentSession.uri('codex', 'host-session'); + const session = AgentSession.uri(scheme, 'host-session'); const chat = URI.parse(buildDefaultChatUri(session)); const folder = URI.file('/repo/durable'); const first = await createAgent(disposables, { sdkResolvableWithoutDownload: true, sessionStore }); @@ -4445,6 +4679,7 @@ suite('CodexAgent chat backing durability', () => { backingSession: receipt.result?.backingSession?.toString(), restoredThreadId: restored?.threadId, restoredSessionUri: restored?.sessionUri.toString(), + restoredConfigurationResource: restored?.configurationResource.toString(), restoredChatChannel: restored?.chatChannel?.toString(), hasAmbientRuntime: second['_sessions'].has('codex-thread'), ambientUnsubscribe: { method: ambientUnsubscribe.method, threadId: ambientUnsubscribe.params.threadId }, @@ -4463,7 +4698,8 @@ suite('CodexAgent chat backing durability', () => { backingSessionId: 'host-session', backingSession: AgentSession.uri('codex', 'codex-thread').toString(), restoredThreadId: 'codex-thread', - restoredSessionUri: session.toString(), + restoredSessionUri: AgentSession.uri('codex', 'host-session').toString(), + restoredConfigurationResource: session.toString(), restoredChatChannel: chat.toString(), hasAmbientRuntime: false, ambientUnsubscribe: { method: 'thread/unsubscribe', threadId: 'codex-thread' }, @@ -4481,7 +4717,7 @@ suite('CodexAgent chat backing durability', () => { firstPeer.dispose(); secondPeer?.dispose(); } - }); + })); test('a restored runtime is addressed by the id its backing names, never by the session that asked for it', async () => { const agent = await createAgent(disposables, { sdkResolvableWithoutDownload: true, sessionStore: createTestSessionStore() }); @@ -4653,15 +4889,15 @@ suite('CodexAgent chat backing durability', () => { const session = AgentSession.uri('codex', 'metadata-owner'); const defaultChat = URI.parse(buildDefaultChatUri(session)); const peerChat = URI.parse(buildChatUri(session, 'metadata-peer')); - await agent.materializeChat(defaultChat, { configurationResource: session, resource: defaultChat }, JSON.stringify({ sessionId: 'default-runtime' })); - await agent.materializeChat(peerChat, { configurationResource: session, resource: peerChat }, JSON.stringify({ sessionId: 'peer-runtime' })); + await agent.materializeChat(defaultChat, { configurationResource: session, resource: defaultChat }, JSON.stringify({ sessionId: 'default-runtime', model: { id: 'gpt-test' } })); + await agent.materializeChat(peerChat, { configurationResource: session, resource: peerChat }, JSON.stringify({ sessionId: 'peer-runtime', model: { id: 'gpt-test' } })); agent['_sessions'].get('default-runtime')!.workingDirectory = URI.file('/repo/default'); agent['_sessions'].get('peer-runtime')!.workingDirectory = URI.file('/repo/peer'); const metadata = await agent.getChatMetadata( peerChat, { configurationResource: session, resource: peerChat }, - JSON.stringify({ sessionId: 'peer-runtime' }), + JSON.stringify({ sessionId: 'peer-runtime', model: { id: 'gpt-test' } }), ); assert.deepStrictEqual({ 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 44206f9f488b55..b3daf0a4250244 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexMapAppServerEvents.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexMapAppServerEvents.test.ts @@ -762,7 +762,7 @@ suite('codexMapAppServerEvents', () => { }); }); - test('imageGeneration item maps to an image tool call lifecycle', () => { + test('imageGeneration item maps to an image tool call lifecycle with its final prompt', () => { const state = createCodexSessionMapState(); const startActions = mapItemStarted(state, { item: { type: 'imageGeneration', id: 'image_1', status: 'in_progress', revisedPrompt: null, result: '', failure: null }, @@ -789,10 +789,17 @@ suite('codexMapAppServerEvents', () => { turnId: 'turn_a', toolCallId, invocationMessage: 'Generating image', - toolInput: '{"prompt":"Generate image"}', + toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded, }], complete: [{ + type: ActionType.ChatToolCallReady, + turnId: 'turn_a', + toolCallId, + invocationMessage: 'Generating image', + toolInput: '{"prompt":"A watercolor fox"}', + confirmed: ToolCallConfirmationReason.NotNeeded, + }, { type: ActionType.ChatToolCallComplete, turnId: 'turn_a', toolCallId, @@ -806,6 +813,37 @@ suite('codexMapAppServerEvents', () => { }); }); + for (const [status, result, success] of [ + ['completed', 'aW1hZ2U=', true], + ['failed', 'aW1hZ2U=', false], + ['completed', '', false], + ['incomplete', '', false], + ] as const) { + test(`imageGeneration ${status} with ${result ? 'image data' : 'no image'} preserves its outcome without inventing a prompt`, () => { + const state = createCodexSessionMapState(); + mapItemStarted(state, { + item: { type: 'imageGeneration', id: 'image_1', status: 'in_progress', revisedPrompt: null, result: '', failure: null }, + threadId: 'thr_1', turnId: 'turn_a', startedAtMs: 0, + }); + const toolCallId = state.itemToToolCall.get('image_1')!.toolCallId; + const actions = mapItemCompleted(state, { + item: { type: 'imageGeneration', id: 'image_1', status, revisedPrompt: null, result, failure: null }, + threadId: 'thr_1', turnId: 'turn_a', completedAtMs: 0, + }); + assert.deepStrictEqual(actions, [{ + type: ActionType.ChatToolCallComplete, + turnId: 'turn_a', + toolCallId, + result: { + success, + pastTenseMessage: success ? 'Generated image' : 'Failed to generate image', + content: success ? [{ type: ToolResultContentType.EmbeddedResource, data: result, contentType: 'image/png' }] : undefined, + ...(success ? {} : { error: { message: `Image generation ${status}` } }), + }, + }]); + }); + } + test('fileChange item maps to file edit tool call lifecycle', () => { const state = createCodexSessionMapState(); const changes = [{ path: 'src/a.ts', kind: { type: 'update', move_path: null }, diff: '@@ -1 +1 @@\n-old\n+new' }] as const; diff --git a/src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts b/src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts index 9e9d08426f8160..4a04dfde201b30 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts @@ -45,6 +45,7 @@ import { createTestAgentHostProxyResolver } from '../agentServiceTestUtils.js'; import { readCodexAccountInfo } from '../../../common/codexAccount.js'; import type { GetAccountResponse } from '../../../node/codex/protocol/generated/v2/GetAccountResponse.js'; import type { GetAccountRateLimitsResponse } from '../../../node/codex/protocol/generated/v2/GetAccountRateLimitsResponse.js'; +import { createNullSessionDataService } from '../../common/sessionTestHelpers.js'; interface ITestAgentContext { readonly agent: CodexAgent; @@ -65,7 +66,7 @@ function createAgentContext(disposables: Pick, models: ( const stateManager = disposables.add(new AgentHostStateManager(logService)); const configurationService = disposables.add(new AgentConfigurationService(stateManager, logService)); configurationService.updateRootConfig(rootConfig); - instantiationService.stub(ISessionDataService, { _serviceBrand: undefined }); + instantiationService.stub(ISessionDataService, createNullSessionDataService()); instantiationService.stub(ICopilotApiService, { _serviceBrand: undefined, models }); instantiationService.stub(ICodexProxyService, { _serviceBrand: undefined }); instantiationService.stub(IAgentConfigurationService, configurationService); @@ -195,7 +196,7 @@ suite('CodexAgent model refresh', () => { metadata: undefined, migrated: AgentChatMigrationDeferred, models: [], - account: { status: 'unknown', email: undefined, planType: undefined, profileImage: undefined, requiresOpenaiAuth: undefined, rateLimit: undefined, rateLimits: undefined, authUrl: undefined, authUrlNonce: undefined }, + account: { status: 'unknown', email: undefined, planType: undefined, profileImage: undefined, requiresOpenaiAuth: undefined, observedAt: undefined, rateLimit: undefined, rateLimits: undefined, authUrl: undefined, authUrlNonce: undefined }, }); // Even an ambient catalog refresh must not cross the session boundary. @@ -550,7 +551,7 @@ suite('CodexAgent model refresh', () => { }, { requests: ['account/read', 'account/login/start', 'account/read', 'account/rateLimits/read', 'getAuthStatus'], disposed: ['client', 'proxy', 'child'], - account: { status: 'signedIn', email: 'person@example.com', planType: 'plus', profileImage: undefined, requiresOpenaiAuth: true, rateLimit: undefined, rateLimits: [], authUrl: undefined, authUrlNonce: undefined }, + account: { status: 'signedIn', email: 'person@example.com', planType: 'plus', profileImage: undefined, requiresOpenaiAuth: true, observedAt: undefined, rateLimit: undefined, rateLimits: [], authUrl: undefined, authUrlNonce: undefined }, connection: 'idle', }); }); @@ -600,6 +601,7 @@ suite('CodexAgent model refresh', () => { planType: 'plus', profileImage: undefined, requiresOpenaiAuth: true, + observedAt: undefined, rateLimit: undefined, rateLimits: undefined, authUrl: undefined, 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 14faa23f943f4f..189abf0579ab85 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexPrewarmEviction.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexPrewarmEviction.test.ts @@ -2764,7 +2764,7 @@ suite('CodexAgent prewarm eviction', () => { turnAttempts: 3, events: [{ name: 'agentHost.codexProviderSwitch', - data: { fromProvider: 'openai', toProvider: 'copilot', isDesktopThread: false, chatgptAccountState: 'unknown', chatgptWeeklyQuotaState: 'unavailable' }, + data: { fromProvider: 'openai', toProvider: 'copilot', isDesktopThread: false, chatgptAccountState: 'unknown', chatgptWeeklyQuotaState: 'unavailable', chatgptFiveHourQuotaState: 'unavailable' }, }], }); } finally { @@ -2877,7 +2877,7 @@ suite('CodexAgent prewarm eviction', () => { quotaReads: 1, events: [{ name: 'agentHost.codexProviderSwitch', - data: { fromProvider: 'openai', toProvider: 'copilot', isDesktopThread: false, chatgptAccountState: 'signedIn', chatgptPlanTier: 'unknown', chatgptWeeklyQuotaState: 'available', chatgptWeeklyUsedPercentBucket: 90 }, + data: { fromProvider: 'openai', toProvider: 'copilot', isDesktopThread: false, chatgptAccountState: 'signedIn', chatgptPlanTier: 'unknown', chatgptWeeklyQuotaState: 'available', chatgptWeeklyUsedPercentBucket: 90, chatgptFiveHourQuotaState: 'missing' }, }], }); }); @@ -2953,7 +2953,7 @@ suite('CodexAgent prewarm eviction', () => { method: 'turn/start', events: [{ name: 'agentHost.codexProviderSwitch', - data: { fromProvider: 'openai', toProvider: 'copilot', isDesktopThread: false, chatgptAccountState: 'signedIn', chatgptPlanTier: 'business', chatgptWeeklyQuotaState: 'available', chatgptWeeklyUsedPercentBucket: 90 }, + data: { fromProvider: 'openai', toProvider: 'copilot', isDesktopThread: false, chatgptAccountState: 'signedIn', chatgptPlanTier: 'business', chatgptWeeklyQuotaState: 'available', chatgptWeeklyUsedPercentBucket: 90, chatgptFiveHourQuotaState: 'missing' }, }], }); } finally { @@ -3093,7 +3093,7 @@ suite('CodexAgent prewarm eviction', () => { method: 'turn/start', events: [{ name: 'agentHost.codexProviderSwitch', - data: { fromProvider: 'openai', toProvider: 'copilot', isDesktopThread: false, chatgptAccountState: 'unknown', chatgptWeeklyQuotaState: 'unavailable' }, + data: { fromProvider: 'openai', toProvider: 'copilot', isDesktopThread: false, chatgptAccountState: 'unknown', chatgptWeeklyQuotaState: 'unavailable', chatgptFiveHourQuotaState: 'unavailable' }, }], }); @@ -4283,7 +4283,7 @@ suite('CodexAgent prewarm eviction', () => { success: response.result?.success, approvalPending: entry.pendingCommandApprovals.has('tool-1'), }, { - confirmations: scenario.requiresConfirmation ? ['Continue in app?'] : [], + confirmations: scenario.requiresConfirmation ? ['Change Workspace to app?'] : [], executionsBeforeApproval: scenario.requiresConfirmation ? [0] : [], executions: scenario.approved === false ? [] : [{ chatUri: defaultChatOf(session).toString(), toolName: tool.name }], success: scenario.approved !== false, @@ -4778,7 +4778,7 @@ suite('CodexAgent prewarm eviction', () => { threadId: 'desktop-thread', events: [{ name: 'agentHost.codexProviderSwitch', - data: { fromProvider: 'copilot', toProvider: 'openai', isDesktopThread: true, chatgptAccountState: 'unknown', chatgptWeeklyQuotaState: 'unavailable' }, + data: { fromProvider: 'copilot', toProvider: 'openai', isDesktopThread: true, chatgptAccountState: 'unknown', chatgptWeeklyQuotaState: 'unavailable', chatgptFiveHourQuotaState: 'unavailable' }, }], }); peer.exit(); diff --git a/src/vs/platform/agentHost/test/node/codex/codexProviderSwitchTelemetry.test.ts b/src/vs/platform/agentHost/test/node/codex/codexProviderSwitchTelemetry.test.ts index 07fe5ab2cc07e0..237d951a51f46f 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexProviderSwitchTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexProviderSwitchTelemetry.test.ts @@ -23,6 +23,13 @@ class TestTelemetryService extends NullTelemetryServiceShape { suite('CodexProviderSwitchTelemetry', () => { ensureNoDisposablesAreLeakedInTestSuite(); + test('joins an accepted provider transition to its session, chat and turn', () => { + const telemetry = new TestTelemetryService(); + const correlation = { agentSessionId: 'session', chatSessionId: 'chat', turnId: 'turn' }; + reportCodexProviderSwitch(telemetry, 'openai', 'vscode-proxy', false, undefined, correlation); + assert.deepStrictEqual(telemetry.events, [{ name: 'agentHost.codexProviderSwitch', data: { fromProvider: 'openai', toProvider: 'copilot', isDesktopThread: false, ...correlation } }]); + }); + for (const isDesktopThread of [false, true]) { test(`reports both subscription directions with desktop origin ${isDesktopThread}`, () => { const telemetryService = new TestTelemetryService(); @@ -55,6 +62,7 @@ suite('CodexProviderSwitchTelemetry', () => { data: { fromProvider: 'openai', toProvider: 'copilot', isDesktopThread: true, chatgptAccountState: 'signedIn', chatgptPlanTier: 'plus', chatgptWeeklyQuotaState: 'available', chatgptWeeklyUsedPercentBucket: 40, + chatgptFiveHourQuotaState: 'missing', }, }]); }); @@ -67,7 +75,7 @@ suite('CodexProviderSwitchTelemetry', () => { assert.deepStrictEqual(telemetryService.events, [{ name: 'agentHost.codexProviderSwitch', - data: { fromProvider: 'copilot', toProvider: 'openai', isDesktopThread: false, chatgptAccountState: 'unknown', chatgptWeeklyQuotaState: 'unavailable' }, + data: { fromProvider: 'copilot', toProvider: 'openai', isDesktopThread: false, chatgptAccountState: 'unknown', chatgptWeeklyQuotaState: 'unavailable', chatgptFiveHourQuotaState: 'unavailable' }, }]); }); diff --git a/src/vs/platform/agentHost/test/node/codex/codexReplayMapper.test.ts b/src/vs/platform/agentHost/test/node/codex/codexReplayMapper.test.ts index 00f5d24a7aae6a..624e852a8e6ac7 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexReplayMapper.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexReplayMapper.test.ts @@ -5,6 +5,7 @@ import assert from 'assert'; import { URI } from '../../../../../base/common/uri.js'; +import { upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { readAgentMessageDelegationMeta } from '../../../common/meta/agentMessageDelegationMeta.js'; import { toHostSnapshotAttachmentMeta } from '../../../common/meta/agentSnapshotAttachmentMeta.js'; @@ -12,6 +13,7 @@ import { SessionServerToolName } from '../../../common/serverToolNames.js'; import { ActionType } from '../../../common/state/sessionActions.js'; import { createCodexSessionMapState, mapItemCompleted, mapItemStarted } from '../../../node/codex/codexMapAppServerEvents.js'; import { replayThreadToTurns, type ICodexReplayedCommand } from '../../../node/codex/codexReplayMapper.js'; +import type { Thread } from '../../../node/codex/protocol/generated/v2/Thread.js'; import type { ThreadItem } from '../../../node/codex/protocol/generated/v2/ThreadItem.js'; import { getTurnError, MessageAttachmentKind, MessageKind, ResponsePartKind, ToolCallStatus, ToolResultContentType, TurnState, type ModelSelection } from '../../../common/state/sessionState.js'; @@ -671,37 +673,48 @@ suite('codexReplayMapper', () => { }); }); - test('imageGeneration restores its generated image', () => { - const turns = replayThreadToTurns({ - id: 'thr', - turns: [{ - id: 'turn_a', - items: [ - { type: 'userMessage', id: 'u', content: [{ type: 'text', text: 'draw it', text_elements: [] }] }, - { type: 'imageGeneration', id: 'image_1', status: 'completed', revisedPrompt: 'A watercolor fox', result: 'aW1hZ2U=' }, - ], - itemsView: { type: 'full' } as never, - status: 'completed' as never, - error: null, startedAt: null, completedAt: null, durationMs: null, - }], - } as never); - const part = turns[0].responseParts[0]; - assert.deepStrictEqual(part.kind === ResponsePartKind.ToolCall && part.toolCall.status === ToolCallStatus.Completed ? { - toolName: part.toolCall.toolName, - displayName: part.toolCall.displayName, - toolInput: part.toolCall.toolInput, - success: part.toolCall.success, - pastTenseMessage: part.toolCall.pastTenseMessage, - content: part.toolCall.content, - } : undefined, { - toolName: 'image_gen.imagegen', - displayName: 'Generate image', - toolInput: '{"prompt":"A watercolor fox"}', - success: true, - pastTenseMessage: 'Generated image', - content: [{ type: ToolResultContentType.EmbeddedResource, data: 'aW1hZ2U=', contentType: 'image/png' }], - }); - }); + for (const [status, result, success] of [ + ['completed', 'aW1hZ2U=', true], + ['failed', 'aW1hZ2U=', false], + ['completed', '', false], + ['incomplete', '', false], + ] as const) { + for (const revisedPrompt of [null, 'A watercolor fox']) { + test(`imageGeneration restores ${status} with ${result ? 'image data' : 'no image'} and ${revisedPrompt ? 'a prompt' : 'no prompt'}`, () => { + const turns = replayThreadToTurns(upcastPartial({ + id: 'thr', + turns: [{ + id: 'turn_a', + items: [ + { type: 'userMessage', id: 'u', clientId: null, content: [{ type: 'text', text: 'draw it', text_elements: [] }] }, + { type: 'imageGeneration', id: 'image_1', status, revisedPrompt, result, failure: null }, + ], + itemsView: 'full', + status: 'completed', + error: null, startedAt: null, completedAt: null, durationMs: null, + }], + })); + const part = turns[0].responseParts[0]; + assert.deepStrictEqual(part.kind === ResponsePartKind.ToolCall && part.toolCall.status === ToolCallStatus.Completed ? { + toolName: part.toolCall.toolName, + displayName: part.toolCall.displayName, + toolInput: part.toolCall.toolInput, + success: part.toolCall.success, + pastTenseMessage: part.toolCall.pastTenseMessage, + content: part.toolCall.content, + error: part.toolCall.error, + } : undefined, { + toolName: 'image_gen.imagegen', + displayName: 'Generate image', + toolInput: revisedPrompt ? '{"prompt":"A watercolor fox"}' : undefined, + success, + pastTenseMessage: success ? 'Generated image' : 'Failed to generate image', + content: success ? [{ type: ToolResultContentType.EmbeddedResource, data: result, contentType: 'image/png' }] : undefined, + error: success ? undefined : { message: `Image generation ${status}` }, + }); + }); + } + } test('contextCompaction is restored as a completed /compact turn', () => { const turns = replayThreadToTurns({ diff --git a/src/vs/platform/agentHost/test/node/codex/codexSessionMetadataStore.test.ts b/src/vs/platform/agentHost/test/node/codex/codexSessionMetadataStore.test.ts index 004910adc934e0..72a5f4704d01f8 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexSessionMetadataStore.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexSessionMetadataStore.test.ts @@ -60,6 +60,36 @@ suite('CodexSessionMetadataStore', () => { assert.strictEqual((await store.read(session)).agent, undefined); }); + test('strict metadata writes propagate failures instead of silently succeeding', async () => { + const database = new TestSessionDatabase(); + database.setMetadata = async () => { throw new Error('write failed'); }; + const store = new CodexSessionMetadataStore(createSessionDataService(database), new NullLogService()); + const session = URI.parse('codex:/session'); + await store.write(session, { cwd: URI.file('/source') }); + await assert.rejects(store.write(session, { cwd: URI.file('/worktree') }, true), /write failed/); + }); + + test('metadata read failures remain best-effort', async () => { + const database = new TestSessionDatabase(); + database.getMetadata = async () => { throw new Error('read failed'); }; + const warnings: string[] = []; + const logService = new NullLogService(); + logService.warn = message => warnings.push(message); + const service = createSessionDataService(database); + const store = new CodexSessionMetadataStore(service, logService); + const session = URI.parse('codex:/session'); + const failedRead = await store.read(session); + service.tryOpenDatabase = async () => { throw new Error('open failed'); }; + const failedOpen = await store.read(session); + assert.deepStrictEqual({ failedRead, failedOpen, warnings }, { + failedRead: {}, failedOpen: {}, + warnings: [ + '[Codex] metadata read failed for codex:/session: read failed', + '[Codex] metadata read failed for codex:/session: open failed', + ], + }); + }); + test('ignores malformed working directory metadata', async () => { const database = new TestSessionDatabase(); await database.setMetadata('codex.cwd', '{"cwd":'); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index ddd113d2d3065a..aebe0449fd7113 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -67,7 +67,12 @@ import { ActionType, AuthRequiredReason, type ChatAction, type SessionAction } f import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { AgentHostAuthenticationService, IAgentHostAuthenticationService } from '../../node/agentHostAuthenticationService.js'; -import { IAgentHostWorktreeIsolation, NullAgentHostWorktreeIsolation } from '../../node/shared/worktreeIsolation.js'; +import { detachedWorktreeRecordUri, IAgentHostWorktreeIsolation, NullAgentHostWorktreeIsolation, WorktreeIsolation } from '../../node/shared/worktreeIsolation.js'; +import { writeSessionAdditionalWorktrees } from '../../node/shared/sessionAdditionalWorktrees.js'; +import { AgentHostCatalogSourceResolver } from '../../node/agentHostCatalogSourceResolver.js'; +import { SESSION_WORKING_DIRECTORIES_KEY } from '../../node/shared/persistSessionMetadata.js'; +import { createTestAgentService, getTestAgentStateManager, registerTestAgentProvider } from './agentServiceTestUtils.js'; +import { AgentSessionRegistry } from '../../node/agentSessionRegistry.js'; import { AgentHostManagedSettingsService, IAgentHostManagedSettingsService } from '../../node/agentHostManagedSettingsService.js'; import { AgentHostStateManager, IAgentHostStateManager } from '../../node/agentHostStateManager.js'; import { AgentHostStorageService } from '../../node/agentHostStorageService.js'; @@ -433,7 +438,22 @@ class TestSessionDatabase extends SessionDatabase { private _metadataWriteFailure: { readonly key: string; readonly error: Error } | undefined; private _metadataWriteGate: { readonly key: string; readonly wait: Promise; readonly entered: DeferredPromise } | undefined; readonly metadataWrites: { readonly key: string; readonly value: string }[] = []; + private _metadataReadGate: { readonly wait: Promise; readonly entered: DeferredPromise } | undefined; + gateNextMetadataRead(wait: Promise, entered: DeferredPromise): void { + this._metadataReadGate = { wait, entered }; + } + + override async getMetadataObject>(obj: T): Promise<{ [K in keyof T]: string | undefined }> { + const result = await super.getMetadataObject(obj); + if (this._metadataReadGate) { + const gate = this._metadataReadGate; + this._metadataReadGate = undefined; + gate.entered.complete(); + await gate.wait; + } + return result; + } failNextMetadataWrite(key: string, error: Error): void { this._metadataWriteFailure = { key, error }; } @@ -482,33 +502,39 @@ class TestSessionDataService extends Disposable implements ISessionDataService { openDatabase(session: URI): IReference { const sessionId = AgentSession.id(session); this.openedSessions.push(sessionId); - let db = this._databases.get(sessionId); + let db = this._databases.get(session.toString()); if (!db) { db = this._register(new TestSessionDatabase(':memory:')); - this._databases.set(sessionId, db); + this._databases.set(session.toString(), db); } return { object: db, dispose: () => { } }; } async tryOpenDatabase(session: URI): Promise | undefined> { - const db = this._databases.get(AgentSession.id(session)); + const db = this._databases.get(session.toString()); return db ? { object: db, dispose: () => { } } : undefined; } failNextMetadataWrite(session: URI, key: string, error: Error): void { - const db = this._databases.get(AgentSession.id(session)); + const db = this._databases.get(session.toString()); assert.ok(db, `No database exists for ${session.toString()}`); db.failNextMetadataWrite(key, error); } gateNextMetadataWrite(session: URI, key: string, wait: Promise, entered: DeferredPromise): void { - const db = this._databases.get(AgentSession.id(session)); + const db = this._databases.get(session.toString()); assert.ok(db, `No database exists for ${session.toString()}`); db.gateNextMetadataWrite(key, wait, entered); } + gateNextMetadataRead(session: URI, wait: Promise, entered: DeferredPromise): void { + const db = this._databases.get(session.toString()); + assert.ok(db, `No database exists for ${session.toString()}`); + db.gateNextMetadataRead(wait, entered); + } + metadataWrites(session: URI): readonly { readonly key: string; readonly value: string }[] { - const db = this._databases.get(AgentSession.id(session)); + const db = this._databases.get(session.toString()); assert.ok(db, `No database exists for ${session.toString()}`); return db.metadataWrites; } @@ -857,6 +883,7 @@ interface ICredentialUpdateSession { } class MockCopilotSession { + readonly historyEvents: SessionEvent[] = []; readonly openCanvases: CopilotSession['openCanvases'] = []; readonly mcpStartCalls: string[] = []; readonly mcpStopCalls: string[] = []; @@ -891,7 +918,7 @@ class MockCopilotSession { }, }, eventLog: { - read: async () => ({ events: [], cursor: 'end', hasMore: false, cursorStatus: 'ok' as const }), + read: async () => ({ events: this.historyEvents, cursor: 'end', hasMore: false, cursorStatus: 'ok' as const }), registerInterest: async () => ({ handle: 'sampling-interest' }), releaseInterest: async () => ({ success: true }), }, @@ -1237,10 +1264,7 @@ function createTestAgentContext(disposables: Pick, optio const fileService = options?.fileService ?? disposables.add(new FileService(logService)); const stateManager = disposables.add(new AgentHostStateManager(logService)); const configService = disposables.add(new AgentConfigurationService(stateManager, logService)); - configService.updateRootConfig({ - [AgentHostByokModelsEnabledConfigKey]: true, - ...options?.rootConfig, - }); + configService.updateRootConfig(options?.rootConfig ?? {}); const managedSettingsService = disposables.add(new AgentHostManagedSettingsService()); const telemetryService = options?.telemetryService ?? NullTelemetryService; services.set(ILogService, logService); @@ -1544,6 +1568,27 @@ suite('CopilotAgent', () => { }); suite('MCP authentication during session initialization', () => { + test('standard host sessions reserve independent Copilot backing IDs without changing their address', async () => { + const client = new McpAuthChallengeCopilotClient([]); + const { agent } = createTestAgentContext(disposables, { + copilotClient: client, + sessionDataService: disposables.add(new TestSessionDataService()), + }); + const session = URI.parse('ahp-session:/new-copilot'); + try { + await agent.authenticate('https://api.github.com', 'github-token'); + const result = await provisionSession(agent, { session, workingDirectories: [URI.file('/workspace')] }); + assert.deepStrictEqual({ + session: result.session.toString(), + provisional: result.provisional, + backingProvider: result.backingSession?.scheme, + separateBacking: result.backingSession && AgentSession.id(result.backingSession) !== AgentSession.id(session), + }, { session: session.toString(), provisional: true, backingProvider: 'copilotcli', separateBacking: true }); + } finally { + await disposeAgent(agent); + } + }); + const authenticationParams = (scopes: readonly string[] = TEST_MCP_SCOPES): AuthenticateParams => ({ resource: TEST_MCP_RESOURCE, scopes, @@ -5081,7 +5126,7 @@ suite('CopilotAgent', () => { }); }); - test('changes an exact live default chat from a bare owning session URI and persists provider-owned working-directory state', async () => { + test('reanchors discovery and persists the exact live default chat when replacing its single-chat workspace', async () => { const root = await fs.mkdtemp(`${os.tmpdir()}/agent-set-cwd-`); const previous = URI.file(join(root, 'previous')); const next = URI.file(join(root, 'next')); @@ -5093,9 +5138,14 @@ suite('CopilotAgent', () => { await db.object.setMetadata('copilot.workingDirectory', previous.toString()); await db.object.setMetadata('copilot.workingDirectories', JSON.stringify([previous.toString()])); await db.object.setMetadata('copilot.customizationDirectory', previous.toString()); + await db.object.setMetadata('copilot.project.uri', previous.toString()); + await db.object.setMetadata('copilot.project.displayName', 'previous'); + await db.object.setMetadata('copilot.project.resolved', 'true'); await db.object.setMetadata('agentHost.workspaceless', 'true'); db.dispose(); - const { agent, instantiationService } = createTestAgentContext(disposables, { sessionDataService }); + const gitService = new TestAgentHostGitService(); + gitService.repositoryRoot = next; + const { agent, instantiationService } = createTestAgentContext(disposables, { sessionDataService, gitService }); const mockSession = new MockCopilotSession(); const created = createAgentSessionThroughAgent(agent, instantiationService, { mockSession, workingDirectory: previous }); const activeClient = created.activeClient as { @@ -5112,13 +5162,16 @@ suite('CopilotAgent', () => { const originalSession = getPeerChatStub(agent, chat); const originalSdkSessionId = chatBackings(agent).get(chat.toString())?.sdkSessionId; - await agent.setWorkingDirectory(chat, session, next); + await agent.setChatWorkingDirectory(chat, exactChatContext(session, chat, session), next, { replaceSessionWorkspace: true }); const stored = sessionDataService.openDatabase(session); const metadata = await stored.object.getMetadataObject({ 'copilot.workingDirectory': true, 'copilot.workingDirectories': true, 'copilot.customizationDirectory': true, + 'copilot.project.uri': true, + 'copilot.project.displayName': true, + 'copilot.project.resolved': true, 'agentHost.workspaceless': true, }); stored.dispose(); @@ -5143,9 +5196,27 @@ suite('CopilotAgent', () => { 'copilot.workingDirectory': next.toString(), 'copilot.workingDirectories': JSON.stringify([next.toString()]), 'copilot.customizationDirectory': next.toString(), + 'copilot.project.uri': next.toString(), + 'copilot.project.displayName': 'next', + 'copilot.project.resolved': 'true', 'agentHost.workspaceless': 'true', }, }); + + const restored = createTestAgent(disposables, { + sessionDataService, + copilotClient: new TestCopilotClient([sdkSession('test-session-1', previous.fsPath)]), + }); + try { + await restored.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); + const restoredMetadata = await restored.getChatMetadata(chat, exactChatContext(session, chat, session)); + assert.deepStrictEqual({ + project: restoredMetadata?.project?.uri.toString(), + directories: restoredMetadata?.workingDirectories?.map(directory => directory.toString()), + }, { project: next.toString(), directories: [next.toString()] }); + } finally { + await disposeAgent(restored); + } } finally { await fs.rm(root, { recursive: true, force: true }); await disposeAgent(agent); @@ -5367,6 +5438,652 @@ suite('CopilotAgent', () => { } }); + suite('exact-chat working-directory conversion', () => { + async function createFixture(multiRoot = false) { + const root = await fs.mkdtemp('./.agent-session-cwd-'); + const previous = URI.file(join(process.cwd(), root, 'previous')); + const next = URI.file(join(process.cwd(), root, 'next')); + const secondary = URI.file(join(process.cwd(), root, 'secondary')); + await Promise.all([fs.mkdir(previous.fsPath), fs.mkdir(next.fsPath), fs.mkdir(secondary.fsPath)]); + const sessionDirectories = multiRoot ? [previous, secondary] : [previous]; + const rootConfig = { [AgentHostCopilotMultiRootEnabledConfigKey]: multiRoot }; + const session = AgentSession.uri('copilotcli', 'session-wide'); + const chats = [defaultChatUri(session), URI.parse(buildChatUri(session, 'live-peer')), URI.parse(buildChatUri(session, 'cold-peer'))]; + const resources = [session, ...chats.slice(1)]; + const sdkSessions = [new MockCopilotSession('session-wide'), new MockCopilotSession('live-sdk'), new MockCopilotSession('cold-sdk')]; + const client = new TestCopilotClient(sdkSessions.map(sdk => sdkSession(sdk.sessionId, previous.fsPath))); + const resumed: { id: string; directory: string | undefined }[] = []; + const continuations: { id: string; messages: Parameters[0]['messages'] }[] = []; + let sends = 0; + for (const sdk of sdkSessions) { + sdk.send = async () => { sends++; return ''; }; + Object.assign(sdk.rpc, { + sendMessages: async (params: Parameters[0]) => { + continuations.push({ id: sdk.sessionId, messages: params.messages }); + return {}; + }, + }); + } + client.resumeSession = async (id, options) => { + resumed.push({ id, directory: options.workingDirectory }); + const sdk = sdkSessions.find(sdk => sdk.sessionId === id); + assert.ok(sdk); + return sdk as unknown as CopilotSession; + }; + const sessionDataService = disposables.add(new TestSessionDataService()); + const worktreeIsolation = new NullAgentHostWorktreeIsolation(); + const { agent, fileService } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, useRealResumePath: true, worktreeIsolation, rootConfig }); + const bindChats = async (target: CopilotAgent) => { + await target.authenticate('https://api.github.com', 'token'); + for (const [index, chat] of chats.entries()) { + await target.materializeChat(chat, exactChatContext(session, chat, resources[index]), JSON.stringify({ + sdkSessionId: sdkSessions[index].sessionId, + model: { id: `model-${index}` }, + })); + } + }; + await bindChats(agent); + for (const [index, resource] of resources.entries()) { + const db = sessionDataService.openDatabase(resource); + try { + await db.object.setMetadata('copilot.workingDirectory', previous.toString()); + await db.object.setMetadata('copilot.workingDirectories', JSON.stringify((index === 0 ? sessionDirectories : [previous]).map(directory => directory.toString()))); + await db.object.setMetadata('copilot.customizationDirectory', previous.toString()); + } finally { + db.dispose(); + } + } + await agent.chats.getMessages(chats[1], exactChatContext(session, chats[1])); + const activeClient = (agent as unknown as { + _activeClients: Map; + })._activeClients.get(session)!; + const reanchor = spy(activeClient.pluginController, 'reanchor'); + disposables.add(toDisposable(() => reanchor.restore())); + const reanchors = () => reanchor.getCalls().map(call => call.args[0].toString()); + const metadata = async () => Promise.all(resources.map(async resource => { + const db = sessionDataService.openDatabase(resource); + try { + return await db.object.getMetadataObject({ + 'copilot.workingDirectory': true, + 'copilot.workingDirectories': true, + 'copilot.customizationDirectory': true, + }); + } finally { + db.dispose(); + } + })); + return { + agent, fileService, session, chats, resources, sdkSessions, client, worktreeIsolation, sessionDataService, previous, next, secondary, sessionDirectories, resumed, continuations, reanchors, metadata, activeClient, + sends: () => sends, + pluginDirectory: () => activeClient.pluginController.directory?.toString(), + restore: async () => { + await disposeAgent(agent); + const { agent: restored } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, useRealResumePath: true, worktreeIsolation, rootConfig }); + await bindChats(restored); + return restored; + }, + dispose: async () => { + await disposeAgent(agent); + await fs.rm(root, { recursive: true, force: true }); + }, + }; + } + + for (const exclusive of [false, true]) { + test(`restores host aggregate roots from real Copilot metadata after a main move (exclusive: ${exclusive})`, async () => { + const fixture = await createFixture(); + let restored: CopilotAgent | undefined; + try { + const { agent, session, chats, resources, previous, next, sessionDataService } = fixture; + const context = exactChatContext(session, chats[0], resources[0]); + await agent.chats.getMessages(chats[0], context); + if (exclusive) { + for (const index of [1, 2]) { + await agent.chats.disposeChat(chats[index], exactChatContext(session, chats[index], resources[index])); + } + } + await agent.setChatWorkingDirectory(chats[0], context, next, { replaceSessionWorkspace: exclusive }); + const roots = (exclusive ? [next] : [previous, next]).map(directory => directory.toString()); + const resolver = new AgentHostCatalogSourceResolver({ + isUnpersistedChatBacking: () => false, + worktreeProjectFromRepositoryRoot: () => undefined, + reportMalformedArtifacts: () => assert.fail('Unexpected malformed artifacts'), + }); + const source = { + modifiedTime: Date.now(), status: SessionStatus.Idle, workingDirectories: roots, + chats: [{ uri: chats[0].toString(), kind: 'default' as const, workingDirectories: [next.toString()] }, + ...(!exclusive ? [{ uri: chats[1].toString(), kind: 'peer' as const, workingDirectories: [previous.toString()] }] : [])], + }; + const db = sessionDataService.openDatabase(session); + try { + const warm = await resolver.buildCatalogSyncRequest(session, source, { + [SESSION_WORKING_DIRECTORIES_KEY]: JSON.stringify(roots), + }, false, db); + await db.object.setMetadataValues(warm.legacyMetadata); + if (!exclusive) { + await db.object.setMetadata('peerChats', JSON.stringify([{ + uri: chats[1].toString(), providerData: JSON.stringify({ sdkSessionId: 'live-sdk' }), workingDirectories: [previous.toString()], + }])); + } + restored = await fixture.restore(); + const metadata = await restored.getChatMetadata(chats[0], context); + assert.ok(metadata); + const cold = await resolver.buildCatalogSyncRequest(session, { + ...source, workingDirectories: metadata.workingDirectories?.map(directory => directory.toString()) ?? [], + }, {}, true, db); + const host = disposables.add(createTestAgentService(new NullLogService(), fixture.fileService, sessionDataService, TEST_PRODUCT_SERVICE, new TestAgentHostGitService())); + registerTestAgentProvider(host, restored); + await (host as unknown as { _sessionRegistry: AgentSessionRegistry })._sessionRegistry.register(session, { + provider: restored.id, startTime: metadata.startTime, modifiedTime: metadata.modifiedTime, source: 'explicit', + }, { checkTombstone: true }); + await host.restoreSession(session); + assert.deepStrictEqual({ + provider: metadata.workingDirectories?.map(directory => directory.toString()), + catalog: cold.data.workingDirectories, + persisted: await db.object.getMetadata(SESSION_WORKING_DIRECTORIES_KEY), + restored: getTestAgentStateManager(host).getSessionSummary(session.toString())?.workingDirectories, + }, { provider: [next.toString()], catalog: roots, persisted: JSON.stringify(roots), restored: roots }); + host.dispose(); + } finally { + db.dispose(); + } + } finally { + if (restored) { + await disposeAgent(restored); + } + await fixture.dispose(); + } + }); + } + + for (const [index, archived, operation] of [ + [0, false, 'send'], [0, true, 'send'], [1, false, 'send'], [1, true, 'send'], + [0, true, 'continue'], [1, true, 'continue'], + ] as const) { + test(`resumes an isolated ${index === 0 ? 'main' : 'peer'} using its detached ownership record and ${operation}s after recovery (archived: ${archived})`, async () => { + const fixture = await createFixture(); + let restored: CopilotAgent | undefined; + try { + const { agent, session, chats, resources, previous, next, secondary, sessionDataService } = fixture; + const context = exactChatContext(session, chats[index], resources[index]); + await agent.chats.getMessages(chats[index], context); + await agent.setChatWorkingDirectory(chats[index], context, next); + fixture.sdkSessions[index].historyEvents.push({ + id: 'saved-question', timestamp: '2026-01-01T00:00:00Z', parentId: null, + type: 'user.message', data: { content: 'Remember the isolated conversation' }, + }, { + id: 'saved-answer', timestamp: '2026-01-01T00:00:01Z', parentId: 'saved-question', + type: 'assistant.message', data: { messageId: 'saved-answer', content: 'Remembered' }, + }, { + id: 'saved-idle', timestamp: '2026-01-01T00:00:02Z', parentId: 'saved-answer', + type: 'session.idle', ephemeral: true, data: {}, + }); + const handle = 'c9373c68-e1ee-47c8-9466-cab0e4791f8d'; + await writeSessionAdditionalWorktrees(sessionDataService, session, [{ + handle, chat: chats[index].toString(), workingDirectory: next.toString(), repositoryRoot: secondary.toString(), + }]); + const owner = sessionDataService.openDatabase(session); + const detached = sessionDataService.openDatabase(detachedWorktreeRecordUri(handle)); + try { + await owner.object.setMetadataValues({ + 'copilot.worktree.branchName': 'owner-branch', + 'copilot.worktree.path': previous.toString(), + 'copilot.worktree.repositoryRoot': previous.toString(), + }); + await detached.object.setMetadataValues({ + 'copilot.worktree.branchName': 'peer-branch', + 'copilot.worktree.path': next.toString(), + 'copilot.worktree.repositoryRoot': secondary.toString(), + [AH_META_IS_ARCHIVED_DB_KEY]: String(archived), + }); + } finally { + owner.dispose(); + detached.dispose(); + } + const git = new class extends TestAgentHostGitService { + override async removeWorktree(repositoryRoot: URI, worktree: URI): Promise { + await super.removeWorktree(repositoryRoot, worktree); + await fs.rm(worktree.fsPath, { recursive: true }); + } + override async addExistingWorktree(repositoryRoot: URI, worktree: URI, branch: string): Promise { + await super.addExistingWorktree(repositoryRoot, worktree, branch); + await fs.mkdir(worktree.fsPath, { recursive: true }); + } + }(); + git.existingBranches.add('peer-branch'); + const isolation = disposables.add(new WorktreeIsolation({ + _serviceBrand: undefined, generateBranchName: async () => 'unused', + }, git, sessionDataService, new NullLogService())); + if (archived) { + await isolation.setDetachedWorktreeArchived(handle, true); + } else { + await fs.rm(next.fsPath, { recursive: true }); + } + fixture.worktreeIsolation.resolveWorkingDirectoryForResume = (resource, id, directory) => isolation.resolveWorkingDirectoryForResume(resource, id, directory); + restored = await fixture.restore(); + assert.ok(restored.chats.resumeTurn); + const history = await restored.chats.getMessages(chats[index], context); + const historyDirectory = getPeerChatStub(restored, chats[index])?.workingDirectory?.toString(); + if (archived) { + await assert.rejects( + operation === 'send' + ? restored.chats.sendMessage(chats[index], 'Do not execute in the repository fallback', [next], undefined, undefined, undefined, context) + : restored.chats.resumeTurn(chats[index], 'continued-turn', context), + /fallback directory for history only/, + ); + assert.deepStrictEqual({ sends: fixture.sends(), continuations: fixture.continuations }, { sends: 0, continuations: [] }); + await isolation.setDetachedWorktreeArchived(handle, false); + } + // Exercise primary-directory refresh even without a new root snapshot. + if (operation === 'send') { + await restored.chats.sendMessage(chats[index], 'Continue the saved conversation', undefined, undefined, undefined, undefined, context); + } else { + await restored.chats.resumeTurn(chats[index], 'continued-turn', context); + } + const recoveredHistory = await restored.chats.getMessages(chats[index], context); + assert.deepStrictEqual({ + historyDirectory, + resumed: fixture.resumed.at(-1)?.directory, + recreated: git.addedExistingWorktrees.map(worktree => ({ directory: worktree.worktree.toString(), branch: worktree.branchName })), + persisted: (await fixture.metadata())[index]['copilot.workingDirectory'], + removed: git.removedWorktrees.map(worktree => worktree.worktree.toString()), + history: history.map(turn => ({ state: turn.state, message: turn.message.text, responses: turn.responseParts.map(part => part.kind === ResponsePartKind.Markdown ? { kind: part.kind, content: part.content } : { kind: part.kind }) })), + recoveredHistory: recoveredHistory.map(turn => ({ state: turn.state, message: turn.message.text, responses: turn.responseParts.map(part => part.kind === ResponsePartKind.Markdown ? { kind: part.kind, content: part.content } : { kind: part.kind }) })), + sends: fixture.sends(), + continuations: fixture.continuations, + turnId: getPeerChatStub(restored, chats[index])?.currentTurnId, + }, { + historyDirectory: (archived ? secondary : next).toString(), + resumed: next.fsPath, + recreated: [{ directory: next.toString(), branch: 'peer-branch' }], + persisted: next.toString(), + removed: archived ? [next.toString()] : [], + history: [{ state: TurnState.Complete, message: 'Remember the isolated conversation', responses: [{ kind: ResponsePartKind.Markdown, content: 'Remembered' }] }], + recoveredHistory: [{ state: TurnState.Complete, message: 'Remember the isolated conversation', responses: [{ kind: ResponsePartKind.Markdown, content: 'Remembered' }] }], + sends: operation === 'send' ? 1 : 0, + continuations: operation === 'send' ? [] : [{ id: fixture.sdkSessions[index].sessionId, messages: [] }], + turnId: operation === 'send' ? undefined : 'continued-turn', + }); + } finally { + if (restored) { + await disposeAgent(restored); + } + await fixture.dispose(); + } + }); + } + + test('creating a peer first after a cold main-only move preserves persisted shared discovery roots', async () => { + const fixture = await createFixture(true); + let restored: CopilotAgent | undefined; + try { + const { agent, session, chats, resources, previous, next, secondary, client } = fixture; + await agent.chats.getMessages(chats[0], exactChatContext(session, chats[0], resources[0])); + await agent.chats.sendMessage(chats[0], 'Continue with one root', [previous], undefined, undefined, undefined, exactChatContext(session, chats[0], resources[0])); + fixture.activeClient.pluginController.setAdditionalDirectories([secondary]); + await agent.setChatWorkingDirectory(chats[0], exactChatContext(session, chats[0], resources[0]), next); + restored = await fixture.restore(); + const peer = URI.parse(buildChatUri(session, 'created-first')); + const sdk = new MockCopilotSession('created-first-sdk'); + let createdDirectory: string | undefined; + client.createSession = async options => { + createdDirectory = options.workingDirectory; + return sdk as unknown as CopilotSession; + }; + await restored.chats.createChat(peer, exactChatContext(session, peer, peer), { workingDirectories: [next] }); + const discoveryAfterCreate = client.skillDiscoveryRequests.at(-1)?.projectPaths; + await restored.chats.sendMessage(chats[1], 'Continue in the original workspace', [previous], undefined, undefined, undefined, exactChatContext(session, chats[1], resources[1])); + const discoveryAfterSibling = client.skillDiscoveryRequests.at(-1)?.projectPaths; + await restored.chats.sendMessage(chats[0], 'Continue in the main workspace', [next], undefined, undefined, undefined, exactChatContext(session, chats[0], resources[0])); + assert.deepStrictEqual({ + createdDirectory, + discoveryAfterCreate, + discoveryAfterSibling, + discoveryAfterMain: client.skillDiscoveryRequests.at(-1)?.projectPaths, + directories: [peer, chats[0], chats[1]].map(chat => getPeerChatStub(restored!, chat)?.workingDirectory?.toString()), + }, { + createdDirectory: next.fsPath, + discoveryAfterCreate: [previous.fsPath, secondary.fsPath], + discoveryAfterSibling: [previous.fsPath, secondary.fsPath], + discoveryAfterMain: [previous.fsPath, secondary.fsPath], + directories: [next.toString(), next.toString(), previous.toString()], + }); + } finally { + if (restored) { + await disposeAgent(restored); + } + await fixture.dispose(); + } + }); + + test('does not overwrite a peer working directory that is persisted while the main chat pins plain peers', async () => { + const fixture = await createFixture(); + try { + const { agent, session, chats, resources, next, secondary, sessionDataService } = fixture; + const db = sessionDataService.openDatabase(resources[1]); + await db.object.deleteMetadata(['copilot.workingDirectory', 'copilot.workingDirectories', 'copilot.customizationDirectory']); + const mainContext = exactChatContext(session, chats[0], resources[0]); + await agent.chats.getMessages(chats[0], mainContext); + await agent.chats.getMessages(chats[1], exactChatContext(session, chats[1], resources[1])); + const readEntered = new DeferredPromise(); + const releaseRead = new DeferredPromise(); + sessionDataService.gateNextMetadataRead(resources[1], releaseRead.p, readEntered); + const moving = agent.setChatWorkingDirectory(chats[0], mainContext, next); + await readEntered.p; + // The pin has already observed the peer as unpinned; the peer now persists its own directory. + await db.object.setMetadataValues({ + 'copilot.workingDirectory': secondary.toString(), + 'copilot.workingDirectories': JSON.stringify([secondary.toString()]), + }); + releaseRead.complete(); + await moving; + db.dispose(); + assert.deepStrictEqual((await fixture.metadata())[1], { + 'copilot.workingDirectory': secondary.toString(), + 'copilot.workingDirectories': JSON.stringify([secondary.toString()]), + 'copilot.customizationDirectory': undefined, + }); + } finally { + await fixture.dispose(); + } + }); + + test('keeps plain peers without their own metadata in their directory when the main chat moves', async () => { + const fixture = await createFixture(); + let restored: CopilotAgent | undefined; + try { + const { agent, session, chats, resources, next, previous, sessionDataService } = fixture; + for (const resource of resources.slice(1)) { + const db = sessionDataService.openDatabase(resource); + await db.object.deleteMetadata(['copilot.workingDirectory', 'copilot.workingDirectories', 'copilot.customizationDirectory']); + db.dispose(); + } + const mainContext = exactChatContext(session, chats[0], resources[0]); + await agent.chats.getMessages(chats[0], mainContext); + await agent.chats.getMessages(chats[1], exactChatContext(session, chats[1], resources[1])); + await agent.setChatWorkingDirectory(chats[0], mainContext, next); + const pinned = (await fixture.metadata()).slice(1); + restored = await fixture.restore(); + const coldContext = exactChatContext(session, chats[2], resources[2]); + // Resume and send without host-supplied directories so only the persisted pin can keep the peer in `previous`. + await restored.chats.getMessages(chats[2], coldContext); + const directoryAfterResume = getPeerChatStub(restored, chats[2])?.workingDirectory?.toString(); + await restored.chats.sendMessage(chats[2], 'hi', undefined, undefined, undefined, undefined, coldContext); + assert.deepStrictEqual({ + pinned, + coldPeerResumes: fixture.resumed.filter(resume => resume.id === 'cold-sdk'), + directoryAfterResume, + coldPeerDirectory: getPeerChatStub(restored, chats[2])?.workingDirectory?.toString(), + }, { + pinned: [1, 2].map(() => ({ + 'copilot.workingDirectory': previous.toString(), + 'copilot.workingDirectories': JSON.stringify([previous.toString()]), + 'copilot.customizationDirectory': undefined, + })), + coldPeerResumes: [{ id: 'cold-sdk', directory: previous.fsPath }], + directoryAfterResume: previous.toString(), + coldPeerDirectory: previous.toString(), + }); + } finally { + if (restored) { + await disposeAgent(restored); + } + await fixture.dispose(); + } + }); + + test('resumes a chat whose additional worktree metadata is unreadable', async () => { + const fixture = await createFixture(); + let restored: CopilotAgent | undefined; + try { + const { agent, session, chats, resources, sessionDataService, previous } = fixture; + await agent.chats.getMessages(chats[0], exactChatContext(session, chats[0], resources[0])); + const db = sessionDataService.openDatabase(resources[0]); + await db.object.setMetadata('agentHost.additionalWorktrees', '{not json'); + db.dispose(); + restored = await fixture.restore(); + await restored.chats.getMessages(chats[0], exactChatContext(session, chats[0], resources[0])); + assert.deepStrictEqual(fixture.resumed.at(-1), { id: 'session-wide', directory: previous.fsPath }); + } finally { + if (restored) { + await disposeAgent(restored); + } + await fixture.dispose(); + } + }); + + test('isolates a live peer without changing the main or cold peer metadata or shared discovery anchor', async () => { + const fixture = await createFixture(); + try { + const before = await fixture.metadata(); + await fixture.agent.setChatWorkingDirectory(fixture.chats[1], exactChatContext(fixture.session, fixture.chats[1], fixture.resources[1]), fixture.next); + assert.deepStrictEqual({ + calls: fixture.sdkSessions.map(sdk => sdk.workingDirectoryCalls), + metadata: await fixture.metadata(), + anchor: fixture.pluginDirectory(), + reanchors: fixture.reanchors(), + sends: fixture.sends(), + }, { + calls: [[], [fixture.next.fsPath], []], + metadata: [before[0], { + 'copilot.workingDirectory': fixture.next.toString(), + 'copilot.workingDirectories': JSON.stringify([fixture.next.toString()]), + 'copilot.customizationDirectory': fixture.next.toString(), + }, before[2]], + anchor: fixture.previous.toString(), reanchors: [], sends: 0, + }); + } finally { + await fixture.dispose(); + } + }); + + for (const [resume, multiRoot] of [ + ['next send', false], ['release and reopen', false], ['cold restart', false], + ['next send', true], ['release and reopen', true], ['cold restart', true], + ] as const) { + test(`preserves an isolated peer workspace and shared discovery through ${resume} and sibling sends (multi-root: ${multiRoot})`, async () => { + const fixture = await createFixture(multiRoot); + let agent = fixture.agent; + try { + const { session, chats, resources, previous, next, sessionDirectories, client } = fixture; + const context = exactChatContext(session, chats[1], resources[1]); + const before = await fixture.metadata(); + await agent.setChatWorkingDirectory(chats[1], context, next); + if (resume === 'cold restart') { + agent = await fixture.restore(); + } else if (resume === 'release and reopen') { + await agent.chats.releaseChat(chats[1], context); + } + await agent.chats.getMessages(chats[1], context); + const reopenedDirectory = getPeerChatStub(agent, chats[1])?.workingDirectory?.toString(); + await agent.chats.sendMessage(chats[1], 'Continue in the isolated workspace', [next], undefined, undefined, undefined, context); + const discoveryAfterPeerSend = client.skillDiscoveryRequests.at(-1)?.projectPaths; + for (const index of [0, 2]) { + await agent.chats.sendMessage(chats[index], 'Continue in the original workspace', index === 0 ? sessionDirectories : [previous], undefined, undefined, undefined, exactChatContext(session, chats[index], resources[index])); + } + await agent.chats.sendMessage(chats[1], 'Continue after the siblings', [next], undefined, undefined, undefined, context); + assert.deepStrictEqual({ + reopenedDirectory, + directories: chats.map(chat => getPeerChatStub(agent, chat)?.workingDirectory?.toString()), + additionalDirectories: chats.map(chat => getPeerChatStub(agent, chat)?.appliedAdditionalDirectories.map(directory => directory.toString())), + discoveryAfterPeerSend, + discoveryAfterSiblingSends: client.skillDiscoveryRequests.at(-1)?.projectPaths, + metadata: await fixture.metadata(), + sends: fixture.sends(), + }, { + reopenedDirectory: next.toString(), + directories: [previous.toString(), next.toString(), previous.toString()], + additionalDirectories: [sessionDirectories.slice(1).map(directory => directory.toString()), [], []], + discoveryAfterPeerSend: sessionDirectories.map(directory => directory.fsPath), + discoveryAfterSiblingSends: sessionDirectories.map(directory => directory.fsPath), + metadata: [before[0], { + 'copilot.workingDirectory': next.toString(), + 'copilot.workingDirectories': JSON.stringify([next.toString()]), + 'copilot.customizationDirectory': next.toString(), + }, before[2]], + sends: 4, + }); + } finally { + if (agent !== fixture.agent) { + await disposeAgent(agent); + } + await fixture.dispose(); + } + }); + } + + for (const resume of ['next send', 'release and reopen', 'cold restart', 'peer-first cold restart'] as const) { + test(`preserves the shared discovery anchor after a main-only workspace change through ${resume}`, async () => { + const fixture = await createFixture(); + let agent = fixture.agent; + try { + const { session, chats, resources, previous, next, client } = fixture; + const context = exactChatContext(session, chats[0], resources[0]); + await agent.chats.getMessages(chats[0], context); + await agent.setChatWorkingDirectory(chats[0], context, next); + const resumeStart = fixture.resumed.length; + if (resume === 'cold restart' || resume === 'peer-first cold restart') { + agent = await fixture.restore(); + if (resume === 'peer-first cold restart') { + await agent.chats.getMessages(chats[1], exactChatContext(session, chats[1], resources[1])); + } + } else if (resume === 'release and reopen') { + await agent.chats.releaseChat(chats[0], context); + } + if (resume !== 'next send') { + await agent.chats.getMessages(chats[0], context); + } + await agent.chats.sendMessage(chats[0], 'Continue in the new main workspace', [next], undefined, undefined, undefined, context); + const discoveryAfterMainSend = client.skillDiscoveryRequests.at(-1)?.projectPaths; + for (const index of [1, 2]) { + await agent.chats.sendMessage(chats[index], 'Continue in the original workspace', [previous], undefined, undefined, undefined, exactChatContext(session, chats[index], resources[index])); + } + await agent.chats.sendMessage(chats[0], 'Continue after the siblings', [next], undefined, undefined, undefined, context); + const db = fixture.sessionDataService.openDatabase(session); + let sharedRoots: string | undefined; + try { + sharedRoots = await db.object.getMetadata('copilot.sharedCustomizationDirectories'); + } finally { + db.dispose(); + } + assert.deepStrictEqual({ + directories: chats.map(chat => getPeerChatStub(agent, chat)?.workingDirectory?.toString()), + mainResumes: fixture.resumed.slice(resumeStart).filter(call => call.id === 'session-wide'), + discoveryAfterMainSend, + discoveryAfterSiblingSends: client.skillDiscoveryRequests.at(-1)?.projectPaths, + sharedRoots, + sends: fixture.sends(), + }, { + directories: [next.toString(), previous.toString(), previous.toString()], + mainResumes: [{ id: 'session-wide', directory: next.fsPath }], + discoveryAfterMainSend: [previous.fsPath], + discoveryAfterSiblingSends: [previous.fsPath], + sharedRoots: JSON.stringify([previous.toString()]), + sends: 4, + }); + } finally { + if (agent !== fixture.agent) { + await disposeAgent(agent); + } + await fixture.dispose(); + } + }); + } + + test('retains shared roots on a main-only move and clears obsolete roots when replacing the workspace after removing peers', async () => { + const fixture = await createFixture(true); + let restored: CopilotAgent | undefined; + try { + const { agent, session, chats, resources, previous, next, secondary, activeClient, client } = fixture; + const context = exactChatContext(session, chats[0], resources[0]); + await agent.chats.getMessages(chats[0], context); + await agent.chats.sendMessage(chats[0], 'Continue with one root', [previous], undefined, undefined, undefined, context); + activeClient.pluginController.setAdditionalDirectories([secondary]); + await agent.setChatWorkingDirectory(chats[0], context, next); + await agent.chats.sendMessage(chats[0], 'Continue in the new main workspace', [next], undefined, undefined, undefined, context); + const sharedDiscovery = client.skillDiscoveryRequests.at(-1)?.projectPaths; + for (const index of [1, 2]) { + await agent.chats.disposeChat(chats[index], exactChatContext(session, chats[index], resources[index])); + } + await agent.setChatWorkingDirectory(chats[0], context, secondary, { replaceSessionWorkspace: true }); + await agent.chats.sendMessage(chats[0], 'Continue in the replacement workspace', [secondary], undefined, undefined, undefined, context); + const db = fixture.sessionDataService.openDatabase(session); + let sharedRoots: string | undefined; + try { + sharedRoots = await db.object.getMetadata('copilot.sharedCustomizationDirectories'); + } finally { + db.dispose(); + } + const replacementDiscovery = client.skillDiscoveryRequests.at(-1)?.projectPaths; + const additionalDirectories = activeClient.pluginController.additionalDirectories.map(directory => directory.toString()); + await disposeAgent(agent); + restored = createTestAgentContext(disposables, { + sessionDataService: fixture.sessionDataService, + copilotClient: client, + useRealResumePath: true, + worktreeIsolation: fixture.worktreeIsolation, + rootConfig: { [AgentHostCopilotMultiRootEnabledConfigKey]: true }, + }).agent; + await restored.authenticate('https://api.github.com', 'token'); + await restored.materializeChat(chats[0], context, JSON.stringify({ sdkSessionId: 'session-wide' })); + await restored.chats.sendMessage(chats[0], 'Continue after reopening', [secondary], undefined, undefined, undefined, context); + assert.deepStrictEqual({ + sharedDiscovery, + replacementDiscovery, + reopenedDiscovery: client.skillDiscoveryRequests.at(-1)?.projectPaths, + sharedRoots, + directory: getPeerChatStub(restored, chats[0])?.workingDirectory?.toString(), + additionalDirectories, + }, { + sharedDiscovery: [previous.fsPath, secondary.fsPath], + replacementDiscovery: [secondary.fsPath], + reopenedDiscovery: [secondary.fsPath], + sharedRoots: '', + directory: secondary.toString(), + additionalDirectories: [], + }); + } finally { + if (restored) { + await disposeAgent(restored); + } + await fixture.dispose(); + } + }); + + for (const roots of ['live', 'persisted'] as const) { + test(`rejects a workspace replacement with actual ${roots} multi-root chat state`, async () => { + const fixture = await createFixture(roots === 'live'); + try { + const { agent, session, chats, resources, previous, next, secondary } = fixture; + const context = exactChatContext(session, chats[0], resources[0]); + await agent.chats.getMessages(chats[0], context); + for (const index of [1, 2]) { + await agent.chats.disposeChat(chats[index], exactChatContext(session, chats[index], resources[index])); + } + const db = fixture.sessionDataService.openDatabase(session); + try { + await db.object.setMetadata('copilot.workingDirectories', JSON.stringify( + (roots === 'live' ? [previous] : [previous, secondary]).map(directory => directory.toString()), + )); + } finally { + db.dispose(); + } + await assert.rejects( + agent.setChatWorkingDirectory(chats[0], context, next, { replaceSessionWorkspace: true }), + /multi-root chat/, + ); + assert.deepStrictEqual({ + directory: getPeerChatStub(agent, chats[0])?.workingDirectory?.toString(), + sdkCalls: fixture.sdkSessions[0].workingDirectoryCalls, + }, { directory: previous.toString(), sdkCalls: [] }); + } finally { + await fixture.dispose(); + } + }); + } + + }); + test('rejects an exact chat without a live backing instead of resuming it', async () => { const root = URI.file(await fs.mkdtemp(`${os.tmpdir()}/agent-set-cwd-not-live-`)); const session = AgentSession.uri('copilotcli', 'not-live'); @@ -5973,9 +6690,17 @@ suite('CopilotAgent', () => { { key: 'copilot.workingDirectory', value: next.toString() }, { key: 'copilot.workingDirectories', value: JSON.stringify([next.toString()]) }, { key: 'copilot.customizationDirectory', value: next.toString() }, + { key: 'copilot.sharedCustomizationDirectories', value: '' }, + { key: 'copilot.project.uri', value: '' }, + { key: 'copilot.project.displayName', value: '' }, + { key: 'copilot.project.resolved', value: 'true' }, { key: 'copilot.workingDirectory', value: previous.toString() }, { key: 'copilot.workingDirectories', value: JSON.stringify([previous.toString()]) }, { key: 'copilot.customizationDirectory', value: previous.toString() }, + { key: 'copilot.sharedCustomizationDirectories', value: '' }, + { key: 'copilot.project.uri', value: '' }, + { key: 'copilot.project.displayName', value: '' }, + { key: 'copilot.project.resolved', value: '' }, ], metadata: { 'copilot.workingDirectory': previous.toString(), @@ -6002,6 +6727,9 @@ suite('CopilotAgent', () => { await db.object.setMetadata('copilot.workingDirectory', previous.toString()); await db.object.setMetadata('copilot.workingDirectories', JSON.stringify([previous.toString()])); await db.object.setMetadata('copilot.customizationDirectory', previous.toString()); + await db.object.setMetadata('copilot.project.uri', previous.toString()); + await db.object.setMetadata('copilot.project.displayName', 'previous'); + await db.object.setMetadata('copilot.project.resolved', 'true'); db.dispose(); const { agent, instantiationService } = createTestAgentContext(disposables, { sessionDataService }); const mockSession = new MockCopilotSession(); @@ -6032,6 +6760,9 @@ suite('CopilotAgent', () => { 'copilot.workingDirectory': true, 'copilot.workingDirectories': true, 'copilot.customizationDirectory': true, + 'copilot.project.uri': true, + 'copilot.project.displayName': true, + 'copilot.project.resolved': true, }); stored.dispose(); assert.deepStrictEqual({ @@ -6052,14 +6783,25 @@ suite('CopilotAgent', () => { { key: 'copilot.workingDirectory', value: next.toString() }, { key: 'copilot.workingDirectories', value: JSON.stringify([next.toString()]) }, { key: 'copilot.customizationDirectory', value: next.toString() }, + { key: 'copilot.sharedCustomizationDirectories', value: '' }, + { key: 'copilot.project.uri', value: '' }, + { key: 'copilot.project.displayName', value: '' }, + { key: 'copilot.project.resolved', value: 'true' }, { key: 'copilot.workingDirectory', value: previous.toString() }, { key: 'copilot.workingDirectories', value: JSON.stringify([previous.toString()]) }, { key: 'copilot.customizationDirectory', value: previous.toString() }, + { key: 'copilot.sharedCustomizationDirectories', value: '' }, + { key: 'copilot.project.uri', value: previous.toString() }, + { key: 'copilot.project.displayName', value: 'previous' }, + { key: 'copilot.project.resolved', value: 'true' }, ], metadata: { 'copilot.workingDirectory': previous.toString(), 'copilot.workingDirectories': JSON.stringify([previous.toString()]), 'copilot.customizationDirectory': previous.toString(), + 'copilot.project.uri': previous.toString(), + 'copilot.project.displayName': 'previous', + 'copilot.project.resolved': 'true', }, }); } finally { @@ -6081,6 +6823,9 @@ suite('CopilotAgent', () => { await db.object.setMetadata('copilot.workingDirectory', previous.toString()); await db.object.setMetadata('copilot.workingDirectories', JSON.stringify([previous.toString()])); await db.object.setMetadata('copilot.customizationDirectory', previous.toString()); + await db.object.setMetadata('copilot.project.uri', previous.toString()); + await db.object.setMetadata('copilot.project.displayName', 'previous'); + await db.object.setMetadata('copilot.project.resolved', 'true'); db.dispose(); const { agent, instantiationService } = createTestAgentContext(disposables, { sessionDataService }); const mockSession = new MockCopilotSession(); @@ -6111,6 +6856,9 @@ suite('CopilotAgent', () => { 'copilot.workingDirectory': true, 'copilot.workingDirectories': true, 'copilot.customizationDirectory': true, + 'copilot.project.uri': true, + 'copilot.project.displayName': true, + 'copilot.project.resolved': true, }); stored.dispose(); assert.deepStrictEqual({ @@ -6131,14 +6879,25 @@ suite('CopilotAgent', () => { { key: 'copilot.workingDirectory', value: next.toString() }, { key: 'copilot.workingDirectories', value: JSON.stringify([next.toString()]) }, { key: 'copilot.customizationDirectory', value: next.toString() }, + { key: 'copilot.sharedCustomizationDirectories', value: '' }, + { key: 'copilot.project.uri', value: '' }, + { key: 'copilot.project.displayName', value: '' }, + { key: 'copilot.project.resolved', value: 'true' }, { key: 'copilot.workingDirectory', value: actual.toString() }, { key: 'copilot.workingDirectories', value: JSON.stringify([actual.toString()]) }, { key: 'copilot.customizationDirectory', value: actual.toString() }, + { key: 'copilot.sharedCustomizationDirectories', value: '' }, + { key: 'copilot.project.uri', value: '' }, + { key: 'copilot.project.displayName', value: '' }, + { key: 'copilot.project.resolved', value: 'true' }, ], metadata: { 'copilot.workingDirectory': actual.toString(), 'copilot.workingDirectories': JSON.stringify([actual.toString()]), 'copilot.customizationDirectory': actual.toString(), + 'copilot.project.uri': '', + 'copilot.project.displayName': '', + 'copilot.project.resolved': 'true', }, }); } finally { @@ -13606,6 +14365,13 @@ suite('CopilotAgent', () => { const client = new TestCopilotClient([sdkSession(sdkSessionId, '/workspace')]); const oldRuntime = new MockCopilotSession(sdkSessionId); const newRuntime = new MockCopilotSession(sdkSessionId); + const disconnectStarted = new DeferredPromise(); + const disconnect = stub(oldRuntime, 'disconnect').callsFake(async () => { + oldRuntime.disconnectCalls++; + disconnectStarted.complete(); + await oldRuntime.disconnectGate; + }); + disposables.add(toDisposable(() => disconnect.restore())); const resumeConfigs: Parameters[1][] = []; const resumedIds: string[] = []; let resumeGate: Promise | undefined; @@ -13659,7 +14425,7 @@ suite('CopilotAgent', () => { })); await syncStarted.p; return { - agent, session, chat, context, oldRuntime, newRuntime, resumeConfigs, resumedIds, syncCalls, pluginDirectory, healthyDirectory, refreshStarted, + agent, session, chat, context, oldRuntime, newRuntime, resumeConfigs, resumedIds, syncCalls, pluginDirectory, healthyDirectory, refreshStarted, disconnectStarted, serverId: `${URI.joinPath(pluginDirectory, '.mcp.json')}#mcp=late-server`, otherId: `${URI.joinPath(pluginDirectory, '.mcp.json')}#mcp=other-server`, live: () => chatEntriesBySdkId(agent).get(sdkSessionId)!.chatSession, @@ -13700,12 +14466,18 @@ suite('CopilotAgent', () => { try { const live = h.live(); live.resetTurnState('active-turn'); - const waiting = spy(live, 'waitForIdle'); + const waitStarted = new DeferredPromise(); + const waitForIdle = live.waitForIdle; + const waiting = stub(live, 'waitForIdle').callsFake(token => { + const idle = waitForIdle.call(live, token); + waitStarted.complete(); + return idle; + }); disposables.add(toDisposable(() => waiting.restore())); const source = disposables.add(new CancellationTokenSource()); const start = h.agent.startMcpServer(h.session, h.serverId, source.token); const outcome = start.then(() => undefined, error => error); - await timeout(0); + await waitStarted.p; assert.ok(waiting.calledOnce, 'Start must wait outside the chat queue'); assert.strictEqual(h.oldRuntime.disconnectCalls, 0); await h.agent.chats.getMessages(h.chat, h.context); @@ -13776,7 +14548,7 @@ suite('CopilotAgent', () => { const start = h.agent.startMcpServer(h.session, h.serverId, source.token); const outcome = start.then(() => undefined, error => error); if (phase === 'disconnect') { - await timeout(0); + await h.disconnectStarted.p; assert.strictEqual(h.oldRuntime.disconnectCalls, 1); } else { await h.refreshStarted.p; @@ -13901,7 +14673,7 @@ suite('CopilotAgent', () => { } const cancelled = h.agent.chats.sendMessage(h.chat, 'cancelled', undefined, undefined, 'turn-1', undefined, h.context); if (phase === 'disconnect') { - await timeout(0); + await h.disconnectStarted.p; assert.strictEqual(h.oldRuntime.disconnectCalls, 1); } else { await h.refreshStarted.p; @@ -14549,7 +15321,7 @@ suite('CopilotAgent', () => { /** Internal surface these chat-routing tests reach into to stub the SDK/agent-session seam. */ type ChatInternals = { _chatBackings: Map; - _createAgentSession: (launchPlan: CopilotSessionLaunchPlan, customizationDirectory: URI | undefined, activeClient: unknown, identity?: { sessionUri: URI; chatChannelUri: URI }) => CopilotAgentSession; + _createAgentSession: (launchPlan: CopilotSessionLaunchPlan, customizationDirectory: URI | undefined, activeClient: unknown, identity?: { sessionUri: URI; chatChannelUri: URI; resource: URI }) => CopilotAgentSession; _resumeSession: (sessionId: string, chatChannelUri?: URI) => Promise; _destroyLiveSession: (session: CopilotAgentSession, preserveRouting?: boolean) => Promise; _getOrCreateSessionLifetime: (sessionId: string) => { queueSession(task: () => Promise): Promise } | undefined; @@ -15306,10 +16078,11 @@ suite('CopilotAgent', () => { const internals = agent as unknown as ChatInternals; internals._forkSdkChat = async () => ({ sessionId: 'side-sdk-id', inheritedTurnId: 't1' }); let sideRecorder: IFakeChatRecorder | undefined; - internals._createAgentSession = launchPlan => { + internals._createAgentSession = (launchPlan, _directory, _activeClient, identity) => { const side = makeFakeChatSession(session, launchPlan.sessionId, async () => ( sideRecorder && sideRecorder.sends.length > 0 ? [sourceTurn, sideTurn] : [sourceTurn] ), launchPlan.shellManager); + Object.assign(side.fake, { resourceUri: identity?.resource, workingDirectory: launchPlan.workingDirectory }); sideRecorder = side.rec; return side.fake; }; @@ -15388,10 +16161,11 @@ suite('CopilotAgent', () => { return { sessionId: 'side-sdk-id', inheritedTurnId: 't1' }; }; let sideRecorder: IFakeChatRecorder | undefined; - internals._createAgentSession = launchPlan => { + internals._createAgentSession = (launchPlan, _directory, _activeClient, identity) => { const side = makeFakeChatSession(session, launchPlan.sessionId, async () => ( sideRecorder && sideRecorder.sends.length > 0 ? [sourceTurn, sideTurn] : [sourceTurn] ), launchPlan.shellManager); + Object.assign(side.fake, { resourceUri: identity?.resource, workingDirectory: launchPlan.workingDirectory }); sideRecorder = side.rec; return side.fake; }; @@ -15558,6 +16332,7 @@ suite('CopilotAgent', () => { const built = makeFakeChatSession(session, launchPlan.sessionId, undefined, launchPlan.shellManager); (built.fake as { chatChannelUri?: URI }).chatChannelUri = identity?.chatChannelUri; (built.fake as { appliedAdditionalDirectories?: readonly URI[] }).appliedAdditionalDirectories = launchPlan.additionalDirectories; + (built.fake as { workingDirectory?: URI }).workingDirectory = launchPlan.workingDirectory; return built.fake; }; @@ -15615,6 +16390,7 @@ suite('CopilotAgent', () => { recorder = built.rec; (built.fake as { chatChannelUri?: URI }).chatChannelUri = identity?.chatChannelUri; (built.fake as { appliedAdditionalDirectories?: readonly URI[] }).appliedAdditionalDirectories = launchPlan.additionalDirectories; + (built.fake as { workingDirectory?: URI }).workingDirectory = launchPlan.workingDirectory; return built.fake; }; @@ -17670,7 +18446,7 @@ suite('CopilotAgent', () => { logs: logService.messages, }, { resumeCalls: ['failed-turn'], - logs: [`[Copilot:config-refresh-session] Session configuration changed, refreshing session: operation=resumeTurn, sdkSessionId=config-refresh-session, chat=${chat.toString()}, turnId=failed-turn, reason=workingDirectoryChanged`], + logs: [`[Copilot:config-refresh-session] Session configuration changed, refreshing session: operation=resumeTurn, sdkSessionId=config-refresh-session, chat=${chat.toString()}, turnId=failed-turn, reason=workingDirectoryChanged, clients=[(none)]`], }); } finally { await disposeAgent(agent); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index b6b194552b6937..d2f8086c72fb0c 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -39,7 +39,8 @@ import type { ChatInputRequestWithPlanReview } from '../../common/agentHostPlanR import { AgentFeedbackAttachmentDisplayKind } from '../../common/meta/agentFeedbackAttachments.js'; import { ChatInputRequestPurpose, readChatInputRequestPurpose } from '../../common/meta/agentChatInputRequestMeta.js'; import { readToolCallMeta } from '../../common/meta/agentToolCallMeta.js'; -import { readMcpServerDisplayName, readMcpServerSource } from '../../common/meta/mcpCustomizationMeta.js'; +import { readImageGenerationToolMetadata } from '../../common/meta/agentImageGenerationMeta.js'; +import { readMcpServerControllingSetting, readMcpServerDisplayName, readMcpServerSource, readMcpServerSourcePlugin } from '../../common/meta/mcpCustomizationMeta.js'; import { agentModelCallMetaKey, readAgentModelCallDiagnostics } from '../../common/meta/agentModelCallMeta.js'; import { readAgentRuntimeModelConfiguration } from '../../common/meta/agentModelConfigurationMeta.js'; import { AgentSystemNotificationKind, AgentSystemNotificationSeverity, readAgentSystemNotificationMeta } from '../../common/meta/agentSystemNotificationMeta.js'; @@ -11994,6 +11995,97 @@ Use the attached image as context. ); }); + test('counts every HydraFusion phase model call on turnCompleted', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + session.resetTurnState('fusion-turn'); + mockSession.fire('user.message', { content: 'fix the bug', interactionId: 'interaction-root' }); + const fusion = { fusionId: 'fusion-1', syntheticModel: 'hydrafusion', policy: 'max', pattern: 'cascade' }; + const phaseUsage = (phaseId: string, apiCallId: string) => mockSession.fire('assistant.usage', { + inputTokens: 10, outputTokens: 5, model: 'model-a', apiCallId, fusion: { ...fusion, phaseId }, + } as unknown as SessionEventPayload<'assistant.usage'>['data']); + const modelCallFinished = (id: string, interactionId: string) => mockSession.fireRaw({ + type: 'model.call_finished', + ephemeral: true, + id, + data: { turnId: '3', interactionId, dispatchDurationMs: 100, outcome: 'success', containsBuiltInFileEditRequest: true, editClassifierVersion: 1 }, + }); + + // Each phase runs under its own interaction id; the runtime stages its turn start until commit. + modelCallFinished('before-phase', 'interaction-phase-1'); + mockSession.fire('assistant.fusion_phase_started', fusionTestData.started); + phaseUsage('phase-1', 'call-primary'); + modelCallFinished('call-primary', 'interaction-phase-1'); + mockSession.fire('assistant.fusion_phase_completed', fusionTestData.phaseCompleted); + // A discarded repair phase never replays its messages. + mockSession.fire('assistant.fusion_phase_started', { ...fusionTestData.started, phaseId: 'phase-2', phaseKind: 'repair' }); + phaseUsage('phase-2', 'call-repair'); + mockSession.fire('assistant.fusion_phase_failed', { ...fusionTestData.phaseFailed, phaseId: 'phase-2', phaseKind: 'repair' }); + modelCallFinished('after-phase', 'interaction-unrelated'); + // The committed phase replays its message with the same model call id. + mockSession.fire('assistant.message', { messageId: 'message-1', content: 'done', apiCallId: 'call-primary', fusion: { ...fusion, phaseId: 'phase-1', commitId: 'commit-1' } }); + mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); + + const telemetryService = new CapturingTelemetryService(); + const tracker = disposables.add(new AgentHostTurnTracker( + new AgentHostTelemetryReporter(telemetryService), + disposables.add(new AgentHostClientConnectionService()), + new NullLogService(), + )); + const agent = disposables.add(new MockAgent()); + const chatUri = buildDefaultChatUri(session.resourceUri); + tracker.turnStarted(agent, chatUri, 'fusion-turn', undefined, undefined, 'default', undefined, undefined); + for (const signal of signals) { + if (signal.kind === 'action' && signal.action.type === ActionType.ChatTurnComplete) { + tracker.turnCompleted(chatUri, signal.action.turnId, 'success'); + } else if (signal.kind === 'model_call_completed') { + tracker.modelCallCompleted(chatUri, signal.turnId, signal.modelCallId); + } else if (signal.kind === 'model_call_finished') { + tracker.modelCallFinished(chatUri, signal.turnId, signal.modelCallId, signal.dispatchDurationMs, signal.outcome, signal.containsBuiltInFileEditRequest, signal.editClassifierVersion); + } + } + + assert.deepStrictEqual(telemetryService.events.filter(event => event.eventName === 'agentHost.turnCompleted').map(event => { + const data = event.data as { turnId: string; modelCallCount: number; timeToFirstEdit?: number }; + return { turnId: data.turnId, modelCallCount: data.modelCallCount, timeToFirstEdit: data.timeToFirstEdit }; + }), [{ turnId: 'fusion-turn', modelCallCount: 2, timeToFirstEdit: 100 }]); + }); + + test('does not adopt late model calls from an ended turn into a replacement turn\'s Fusion phase', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + session.resetTurnState('old-turn'); + mockSession.fire('user.message', { content: 'first', interactionId: 'interaction-old' }); + mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); + + session.resetTurnState('replacement-turn'); + mockSession.fire('user.message', { content: 'second', interactionId: 'interaction-new' }); + mockSession.fire('assistant.fusion_phase_started', fusionTestData.started); + const modelCallFinished = (id: string, interactionId: string | undefined) => mockSession.fireRaw({ + type: 'model.call_finished', + ephemeral: true, + id, + data: { turnId: '0', interactionId, dispatchDurationMs: 100, outcome: 'success', containsBuiltInFileEditRequest: true, editClassifierVersion: 1 }, + }); + const usage = (fusionId: string, apiCallId: string) => mockSession.fire('assistant.usage', { + inputTokens: 10, outputTokens: 5, model: 'model-a', apiCallId, fusion: { fusionId, phaseId: 'phase-1', syntheticModel: 'hydrafusion', policy: 'max', pattern: 'cascade' }, + } as unknown as SessionEventPayload<'assistant.usage'>['data']); + + modelCallFinished('late-old-call', 'interaction-old'); + modelCallFinished('late-unidentified-call', undefined); + usage('fusion-old', 'late-old-usage'); + modelCallFinished('phase-call', 'interaction-phase'); + usage(fusionTestData.started.fusionId, 'phase-call'); + + assert.deepStrictEqual( + signals + .filter(signal => signal.kind === 'model_call_finished' || signal.kind === 'model_call_completed') + .map(signal => ({ kind: signal.kind, modelCallId: signal.modelCallId, turnId: signal.turnId })), + [ + { kind: 'model_call_finished', modelCallId: 'phase-call', turnId: 'replacement-turn' }, + { kind: 'model_call_completed', modelCallId: 'phase-call', turnId: 'replacement-turn' }, + ], + ); + }); + for (const ending of ['complete', 'abort', 'fail', 'discard', 'replace', 'dispose'] as const) { test(`releases model-call correlations when a host turn ends via ${ending}`, async () => { const { session, mockSession, signals } = await createAgentSession(disposables); @@ -12334,7 +12426,7 @@ Use the attached image as context. deltas: [{ content: '', hasInvocationMessage: true }], readyContributor: { kind: ToolCallContributorKind.MCP, - customizationId: 'mcp-top-level:copilot:test-session-1:docs', + customizationId: 'mcp-top-level:copilotcli:test-session-1:docs', }, invocationMessage: 'Look up documentation', }); @@ -12420,6 +12512,95 @@ Use the attached image as context. }]); }); + test('image function tools retain streamed text and precede the final answer', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + session.resetTurnState('turn-image-order'); + const message = { + messageId: 'image-request', + content: 'I will create an image.', + toolRequests: [{ toolCallId: 'image-1', name: 'image_generation', arguments: { prompt: 'Draw a puppy' } }], + }; + mockSession.fire('assistant.message_delta', { + messageId: message.messageId, + deltaContent: message.content, + }); + mockSession.fire('assistant.message', message); + mockSession.fire('tool.execution_start', { + toolCallId: 'image-1', + toolName: 'image_generation', + arguments: { prompt: 'Draw a puppy' }, + }); + mockSession.fire('tool.execution_complete', { + toolCallId: 'image-1', + success: true, + result: { + content: 'Image data was returned to the client.', + contents: [{ type: 'image', data: 'aW1hZ2U=', mimeType: 'image/png' }], + }, + }); + mockSession.fire('assistant.message', { messageId: 'after-image', content: 'Image generation completed.' }); + + assert.deepStrictEqual(getActions(signals).flatMap(action => { + if (action.type === ActionType.ChatToolCallComplete) { + return [action.toolCallId]; + } + return action.type === ActionType.ChatResponsePart && action.part.kind === ResponsePartKind.Markdown + ? [action.part.content] : []; + }), [message.content, 'image-1', 'Image generation completed.']); + }); + + test('tool completion preserves generated image bytes without advertising opaque resource links', async () => { + const { session, mockSession, signals, waitForSignal } = await createAgentSession(disposables); + session.resetTurnState('turn-image'); + const uri = 'generated-images:/session/generated-image.png?version=1'; + const imageGeneration = { requestedModel: { id: 'image-preview', name: 'Image Preview' } }; + mockSession.fire('tool.execution_start', { + toolCallId: 'tc-image', + toolName: 'image_generation', + model: 'claude-sonnet-5', + }); + const progress = { + toolCallId: 'tc-image', + progressMessage: 'Generating image', + structuredContent: { imageGeneration }, + }; + mockSession.fire('tool.execution_progress', progress); + mockSession.fire('tool.execution_complete', { + toolCallId: 'tc-image', + success: true, + result: { + content: 'Generated images.', + structuredContent: { imageGeneration }, + contents: [ + { type: 'image', data: 'aW1hZ2U=', mimeType: 'image/png' }, + { type: 'resource_link', uri, name: 'generated-image.png', mimeType: 'image/png', size: 128 }, + ], + }, + }); + await waitForSignal(signal => isAction(signal, ActionType.ChatToolCallComplete)); + + const completed = getActions(signals).find(action => action.type === ActionType.ChatToolCallComplete); + const progressActions = () => getActions(signals).filter(action => action.type === ActionType.ChatToolCallContentChanged); + const progressAction = progressActions()[0]; + mockSession.fire('tool.execution_progress', progress); + assert.deepStrictEqual({ + content: completed?.result.content, + title: completed?.result.pastTenseMessage, + completedModel: completed && readImageGenerationToolMetadata(completed), + runningModel: progressAction && readImageGenerationToolMetadata(progressAction), + progressActionsAfterCompletion: progressActions().length, + }, { + content: [ + { type: ToolResultContentType.Text, text: 'Generated images.' }, + { type: ToolResultContentType.EmbeddedResource, data: 'aW1hZ2U=', contentType: 'image/png' }, + ], + title: 'Generated image with Image Preview', + completedModel: imageGeneration, + runningModel: imageGeneration, + progressActionsAfterCompletion: 1, + }); + }); + test('tool_start carries MCP App UI metadata from the SDK', async () => { const { mockSession, signals } = await createAgentSession(disposables); mockSession.fire('tool.execution_start', { @@ -12445,54 +12626,58 @@ Use the attached image as context. } }); - test('tool_start carries the MCP App channel when the server is already ready', async () => { - // A tool call cannot begin until its MCP server is Ready, so the - // AHP `mcp://` channel is always available at `tool.execution_start` - // and is published as part of the initial `_meta.ui` payload. - const { mockSession, signals } = await createAgentSession(disposables, { - configureMockSession: m => { - m.mcpListResult = { servers: [{ name: 'docs', status: 'connected' }] }; - }, - }); - mockSession.fire('session.mcp_server_status_changed', { - serverName: 'docs', - status: 'connected', - } as SessionEventPayload<'session.mcp_server_status_changed'>['data']); - mockSession.fire('tool.execution_start', { - toolCallId: 'tc-app-channel', - toolName: 'mcp_tool', - mcpServerName: 'docs', - arguments: { topic: 'metadata' }, - toolDescription: { - name: 'mcp_tool', - _meta: { - ui: { resourceUri: 'ui://docs' }, - }, - }, - } as SessionEventPayload<'tool.execution_start'>['data']); - - const toolStart = signals.find(s => isAction(s, ActionType.ChatToolCallStart)); - assert.ok(toolStart && isAction(toolStart, ActionType.ChatToolCallStart)); - if (toolStart && isAction(toolStart, ActionType.ChatToolCallStart)) { - const action = toolStart.action as ChatToolCallStartAction; - assert.deepStrictEqual({ - contributor: action.contributor, - meta: action._meta, - }, { - contributor: { - kind: ToolCallContributorKind.MCP, - customizationId: 'mcp-top-level:copilot:test-session-1:docs', + for (const scheme of ['copilot', 'copilotcli', 'ahp-session']) { + test(`tool_start carries the MCP App channel when the server is already ready (${scheme})`, async () => { + const owningSession = AgentSession.uri(scheme, 'test-session-1'); + // A tool call cannot begin until its MCP server is Ready, so the + // AHP `mcp://` channel is always available at `tool.execution_start` + // and is published as part of the initial `_meta.ui` payload. + const { mockSession, signals } = await createAgentSession(disposables, { + sessionUri: owningSession, + configureMockSession: m => { + m.mcpListResult = { servers: [{ name: 'docs', status: 'connected' }] }; }, - meta: { - mcpServerName: 'docs', - ui: { - resourceUri: 'ui://docs', - channel: buildMcpChannel(URI.parse(buildDefaultChatUri(AgentSession.uri('copilot', 'test-session-1'))), 'docs'), + }); + mockSession.fire('session.mcp_server_status_changed', { + serverName: 'docs', + status: 'connected', + } as SessionEventPayload<'session.mcp_server_status_changed'>['data']); + mockSession.fire('tool.execution_start', { + toolCallId: 'tc-app-channel', + toolName: 'mcp_tool', + mcpServerName: 'docs', + arguments: { topic: 'metadata' }, + toolDescription: { + name: 'mcp_tool', + _meta: { + ui: { resourceUri: 'ui://docs' }, }, }, - }); - } - }); + } as SessionEventPayload<'tool.execution_start'>['data']); + + const toolStart = signals.find(s => isAction(s, ActionType.ChatToolCallStart)); + assert.ok(toolStart && isAction(toolStart, ActionType.ChatToolCallStart)); + if (toolStart && isAction(toolStart, ActionType.ChatToolCallStart)) { + const action = toolStart.action as ChatToolCallStartAction; + assert.deepStrictEqual({ + contributor: action.contributor, + meta: action._meta, + }, { + contributor: { + kind: ToolCallContributorKind.MCP, + customizationId: 'mcp-top-level:copilotcli:test-session-1:docs', + }, + meta: { + mcpServerName: 'docs', + ui: { + resourceUri: 'ui://docs', + channel: buildMcpChannel(URI.parse(buildDefaultChatUri(owningSession)), 'docs', 'copilotcli'), + }, + }, + }); + } + }); + } test('tool_start derives intention from a shell tool description argument', async () => { const { session, mockSession, signals } = await createAgentSession(disposables); @@ -17361,7 +17546,7 @@ Use the attached image as context. }; const activeClientToolSet = new ActiveClientToolSet(); activeClientToolSet.set('tool-search-client', toolSearchSnapshot.tools); - const serverCustomizationId = 'mcp-top-level:copilot:test-session-1:docs'; + const serverCustomizationId = 'mcp-top-level:copilotcli:test-session-1:docs'; const created = await createAgentSession(disposables, { clientSnapshot: toolSearchSnapshot, activeClientToolSet, @@ -18569,6 +18754,42 @@ Use the attached image as context. assert.deepStrictEqual(runtime.createServerSdkTools().map(tool => tool.name), ['ephemeralServerTool']); }); + test('rejects inherited topLevelChatOnly tools from native workers and unknown origins', async () => { + const serverToolHost = new FakeServerToolHost([{ name: 'topLevelOnly', topLevelChatOnly: true }]); + const { session, runtime, mockSession } = await createAgentSession(disposables, { serverToolHost }); + session.resetTurnState('turn-top-level-only'); + const tool = runtime.createServerSdkTools()[0]; + mockSession.fire('subagent.started', { + toolCallId: 'worker-task', + agentName: 'helper', + agentDisplayName: 'Helper', + agentDescription: 'Helps', + } as SessionEventPayload<'subagent.started'>['data'], { agentId: 'worker-agent' }); + for (const [toolCallId, agentId] of [['worker-tool', 'worker-agent'], ['unknown-worker-tool', 'unmapped-agent']]) { + mockSession.fire('tool.execution_start', { + toolCallId, toolName: 'topLevelOnly', arguments: {}, + } as SessionEventPayload<'tool.execution_start'>['data'], { agentId }); + } + const results = await Promise.all(['worker-tool', 'unknown-worker-tool', 'missing-origin'].map(toolCallId => invokeClientToolHandler(tool, toolCallId))); + mockSession.fire('tool.execution_start', { + toolCallId: 'root-tool', toolName: 'topLevelOnly', arguments: {}, + } as SessionEventPayload<'tool.execution_start'>['data']); + const rootResult = await invokeClientToolHandler(tool, 'root-tool'); + assert.deepStrictEqual({ + workerResults: results.map(result => result.resultType), + rootResult: rootResult.resultType, + executions: serverToolHost.executions, + }, { + workerResults: ['failure', 'failure', 'failure'], + rootResult: 'success', + executions: [{ + sessionUri: buildDefaultChatUri(AgentSession.uri('copilot', 'test-session-1')), + toolName: 'topLevelOnly', + rawArgs: {}, + }], + }); + }); + test('server tool handler routes to the host and returns a success result', async () => { const serverToolHost = new FakeServerToolHost(); serverToolHost.result = 'listed 2 comments'; @@ -19202,7 +19423,7 @@ Use the attached image as context. ] as const) { test(testCase.name, async () => { const serverName = 'component-explorer'; - const id = 'mcp-top-level:copilot:test-session-1:component-explorer'; + const id = 'mcp-top-level:copilotcli:test-session-1:component-explorer'; const enablement: NonNullable = [{ kind: CustomizationEnablementKind.Global, enabled: testCase.desired }]; const server: McpServerCustomization = { type: CustomizationType.McpServer, @@ -19236,7 +19457,7 @@ Use the attached image as context. test('does not reverse an external disable when an enable reconciliation is already running', async () => { const serverName = 'component-explorer'; - const id = 'mcp-top-level:copilot:test-session-1:component-explorer'; + const id = 'mcp-top-level:copilotcli:test-session-1:component-explorer'; const enablement: NonNullable = [{ kind: CustomizationEnablementKind.Global, enabled: true }]; const enableGate = new DeferredPromise(); const { session, mockSession, signals } = await createAgentSession(disposables, { @@ -19289,7 +19510,7 @@ Use the attached image as context. for (const enabled of [false, true]) { test(`reconciles ${status} servers with desired enablement ${enabled}`, async () => { const serverName = 'azure'; - const id = 'mcp-top-level:copilot:test-session-1:azure'; + const id = 'mcp-top-level:copilotcli:test-session-1:azure'; const { session, mockSession } = await createAgentSession(disposables, { sessionCustomizations: () => [{ type: CustomizationType.McpServer, @@ -19332,7 +19553,7 @@ Use the attached image as context. test('does not enable a server while its customization resolution is pending', async () => { const serverName = 'azure'; - const id = 'mcp-top-level:copilot:test-session-1:azure'; + const id = 'mcp-top-level:copilotcli:test-session-1:azure'; const { session, mockSession } = await createAgentSession(disposables, { sessionCustomizations: () => [{ type: CustomizationType.McpServer, @@ -19354,7 +19575,7 @@ Use the attached image as context. test('does not enable a server resolved as disabled when the SDK reports it disabled', async () => { const serverName = 'azure'; - const id = 'mcp-top-level:copilot:test-session-1:azure'; + const id = 'mcp-top-level:copilotcli:test-session-1:azure'; const { session, mockSession } = await createAgentSession(disposables, { sessionCustomizations: () => [{ type: CustomizationType.McpServer, @@ -19381,7 +19602,7 @@ Use the attached image as context. test('uses a non-starting list to disable a runtime-enabled server without waiting for pending inventory', async () => { const serverName = 'azure'; - const id = 'mcp-top-level:copilot:test-session-1:azure'; + const id = 'mcp-top-level:copilotcli:test-session-1:azure'; const inventoryGate = new DeferredPromise(); const disableGate = new DeferredPromise(); const { session, mockSession } = await createAgentSession(disposables, { @@ -19434,7 +19655,7 @@ Use the attached image as context. test('uses a non-starting list to enable a runtime-disabled server without waiting for pending inventory', async () => { const serverName = 'azure'; - const id = 'mcp-top-level:copilot:test-session-1:azure'; + const id = 'mcp-top-level:copilotcli:test-session-1:azure'; const inventoryGate = new DeferredPromise(); const enableGate = new DeferredPromise(); const { session, mockSession } = await createAgentSession(disposables, { @@ -19487,7 +19708,7 @@ Use the attached image as context. test('does not send when the non-starting MCP list fails', async () => { const serverName = 'azure'; - const id = 'mcp-top-level:copilot:test-session-1:azure'; + const id = 'mcp-top-level:copilotcli:test-session-1:azure'; const { session, mockSession } = await createAgentSession(disposables, { sessionCustomizations: () => [{ type: CustomizationType.McpServer, @@ -19518,7 +19739,7 @@ Use the attached image as context. test('does not send when required MCP disablement fails', async () => { const serverName = 'azure'; - const id = 'mcp-top-level:copilot:test-session-1:azure'; + const id = 'mcp-top-level:copilotcli:test-session-1:azure'; const { session, mockSession } = await createAgentSession(disposables, { sessionCustomizations: () => [{ type: CustomizationType.McpServer, @@ -19552,7 +19773,7 @@ Use the attached image as context. test('uses desired enablement resolved after the non-starting list completes', async () => { const serverName = 'azure'; - const id = 'mcp-top-level:copilot:test-session-1:azure'; + const id = 'mcp-top-level:copilotcli:test-session-1:azure'; const enablementGate = new DeferredPromise(); let desiredEnabled = false; const { session, mockSession } = await createAgentSession(disposables, { @@ -19595,7 +19816,7 @@ Use the attached image as context. test('enables a server resolved as enabled when the SDK reports it disabled', async () => { const serverName = 'azure'; - const id = 'mcp-top-level:copilot:test-session-1:azure'; + const id = 'mcp-top-level:copilotcli:test-session-1:azure'; const { session, mockSession } = await createAgentSession(disposables, { sessionCustomizations: () => [{ type: CustomizationType.McpServer, @@ -19695,7 +19916,7 @@ Use the attached image as context. test('session MCP desired enablement reconciles runtime drift', async () => { const serverName = 'slack'; - const id = 'mcp-top-level:copilot:test-session-1:slack'; + const id = 'mcp-top-level:copilotcli:test-session-1:slack'; let desiredEnabled = true; const { session, mockSession } = await createAgentSession(disposables, { sessionCustomizations: () => [{ @@ -19786,7 +20007,7 @@ Use the attached image as context. test('sending a message does not mark an enabled server as Starting', async () => { const serverName = 'db'; - const id = 'mcp-top-level:copilot:test-session-1:db'; + const id = 'mcp-top-level:copilotcli:test-session-1:db'; const { session } = await createAgentSession(disposables, { sessionCustomizations: () => [{ type: CustomizationType.McpServer, @@ -19818,7 +20039,7 @@ Use the attached image as context. test('backgroundMcpServerStartup accepts a blocking server without waiting for it to connect', async () => { const serverName = 'db'; - const id = 'mcp-top-level:copilot:test-session-1:db'; + const id = 'mcp-top-level:copilotcli:test-session-1:db'; const { session, mockSession } = await createAgentSession(disposables, { sessionCustomizations: () => [{ type: CustomizationType.McpServer, @@ -19855,7 +20076,7 @@ Use the attached image as context. test('backgroundMcpServerStartup preserves blocking when the SDK has no waiting turn to release', async () => { const serverName = 'db'; - const id = 'mcp-top-level:copilot:test-session-1:db'; + const id = 'mcp-top-level:copilotcli:test-session-1:db'; const { session, mockSession } = await createAgentSession(disposables, { sessionCustomizations: () => [{ type: CustomizationType.McpServer, @@ -19890,8 +20111,8 @@ Use the attached image as context. }); test('backgroundMcpServerStartup updates all pending servers after the session-wide SDK request succeeds', async () => { - const first = { name: 'db', id: 'mcp-top-level:copilot:test-session-1:db' }; - const second = { name: 'docs', id: 'mcp-top-level:copilot:test-session-1:docs' }; + const first = { name: 'db', id: 'mcp-top-level:copilotcli:test-session-1:db' }; + const second = { name: 'docs', id: 'mcp-top-level:copilotcli:test-session-1:docs' }; const { session } = await createAgentSession(disposables, { sessionCustomizations: () => [first, second].map(server => ({ type: CustomizationType.McpServer, @@ -19914,7 +20135,7 @@ Use the attached image as context. test('backgroundMcpServerStartup preserves blocking state when the SDK rejects', async () => { const serverName = 'db'; - const id = 'mcp-top-level:copilot:test-session-1:db'; + const id = 'mcp-top-level:copilotcli:test-session-1:db'; const { session, mockSession } = await createAgentSession(disposables, { sessionCustomizations: () => [{ type: CustomizationType.McpServer, @@ -19941,7 +20162,7 @@ Use the attached image as context. for (const status of ['connected', 'failed', 'restart'] as const) { test(`backgroundMcpServerStartup does not overwrite ${status} while awaiting the SDK`, async () => { const serverName = 'db'; - const id = 'mcp-top-level:copilot:test-session-1:db'; + const id = 'mcp-top-level:copilotcli:test-session-1:db'; const gate = new DeferredPromise(); const { session, mockSession } = await createAgentSession(disposables, { configureMockSession: mock => { @@ -19988,7 +20209,7 @@ Use the attached image as context. test('startMcpServer reports Starting only once the SDK reports the reconnect', async () => { const serverName = 'db'; - const id = 'mcp-top-level:copilot:test-session-1:db'; + const id = 'mcp-top-level:copilotcli:test-session-1:db'; const { session, mockSession } = await createAgentSession(disposables, { sessionCustomizations: () => [{ type: CustomizationType.McpServer, @@ -20026,7 +20247,7 @@ Use the attached image as context. test('stopMcpServer uses the SDK lifecycle method', async () => { const serverName = 'db'; - const id = 'mcp-top-level:copilot:test-session-1:db'; + const id = 'mcp-top-level:copilotcli:test-session-1:db'; const { session, mockSession } = await createAgentSession(disposables, { sessionCustomizations: () => [{ type: CustomizationType.McpServer, @@ -20053,7 +20274,7 @@ Use the attached image as context. test('retries current inventory after stopping during an in-flight inventory refresh', async () => { const serverName = 'db'; - const id = 'mcp-top-level:copilot:test-session-1:db'; + const id = 'mcp-top-level:copilotcli:test-session-1:db'; const { session, runtime, mockSession, waitForSignal } = await createAgentSession(disposables, { sessionCustomizations: () => [{ type: CustomizationType.McpServer, @@ -20100,7 +20321,7 @@ Use the attached image as context. test('startMcpServer waits for an in-flight stop of the same server', async () => { const serverName = 'db'; - const id = 'mcp-top-level:copilot:test-session-1:db'; + const id = 'mcp-top-level:copilotcli:test-session-1:db'; const stopGate = new DeferredPromise(); const { session, mockSession } = await createAgentSession(disposables, { sessionCustomizations: () => [{ @@ -20148,7 +20369,7 @@ Use the attached image as context. test('cancels a queued Start without waiting for the preceding Stop or issuing an SDK Start', async () => { const serverName = 'db'; - const id = 'mcp-top-level:copilot:test-session-1:db'; + const id = 'mcp-top-level:copilotcli:test-session-1:db'; const gate = new DeferredPromise(); const source = disposables.add(new CancellationTokenSource()); const { session, mockSession } = await createAgentSession(disposables, { @@ -20175,7 +20396,7 @@ Use the attached image as context. const parentSessionUri = AgentSession.uri('copilot', 'parent-session'); const peerChatUri = URI.parse(buildChatUri(parentSessionUri, 'peer-1')); const serverName = 'slack'; - const id = 'mcp-top-level:copilot:test-session-1:slack'; + const id = 'mcp-top-level:copilotcli:test-session-1:slack'; const { session, mockSession } = await createAgentSession(disposables, { sessionUri: parentSessionUri, chatChannelUri: peerChatUri, @@ -20795,7 +21016,7 @@ Use the attached image as context. test('disabling an MCP server cancels its pending authentication before awaiting the SDK', async () => { const serverName = 'slack'; - const id = 'mcp-top-level:copilot:test-session-1:slack'; + const id = 'mcp-top-level:copilotcli:test-session-1:slack'; let desiredEnabled = true; const { session, mockSession, runtime } = await createAgentSession(disposables, { sessionCustomizations: () => [{ @@ -21060,8 +21281,8 @@ Use the attached image as context. assert.deepStrictEqual(action.customization, { type: 'mcpServer', - id: 'mcp-top-level:copilot:test-session-1:github', - uri: 'mcp-top-level:copilot:test-session-1:github', + id: 'mcp-top-level:copilotcli:test-session-1:github', + uri: 'mcp-top-level:copilotcli:test-session-1:github', name: 'github', state: { kind: McpServerStatus.AuthRequired, @@ -21163,38 +21384,82 @@ Use the attached image as context. assert.deepStrictEqual(names, ['alpha', 'beta']); }); - test('publishes SDK configuration sources for host-only MCP servers and retains them across status updates', async () => { + test('publishes SDK configuration sources and plugins for host-only MCP servers, but not runtime display titles, and retains them across status updates', async () => { const { session, mockSession, waitForSignal } = await createAgentSession(disposables, { configureMockSession: m => { m.mcpListResult = { servers: [ { name: 'local-memory', status: 'connected', source: 'user' }, { name: 'github', status: 'failed', source: 'user' }, - { name: 'workspace-server', status: 'connected', source: 'workspace' }, - { name: 'plugin-server', status: 'connected', source: 'plugin' }, + // The runtime falls back to a server's advertised title; only connector catalog names are display names. + { name: 'workspace-server', status: 'connected', source: 'workspace', displayName: 'Everything Reference Server' }, + { name: 'plugin-server', status: 'connected', source: 'plugin', sourcePlugin: 'acme' }, { name: 'github-mcp-server', status: 'connected', source: 'builtin' }, + { name: 'computer-use', status: 'connected', source: 'builtin', sourcePlugin: 'computer-use' }, + { name: 'github-copilot-connector-1', status: 'connected', source: 'managed' }, ] }; }, }); await waitForSignal(s => isAction(s, ActionType.SessionCustomizationUpdated)); const snapshot = () => session.topLevelMcpCustomizations().map(server => ({ - name: server.name, source: readMcpServerSource(server), + name: server.name, source: readMcpServerSource(server), displayName: readMcpServerDisplayName(server), sourcePlugin: readMcpServerSourcePlugin(server), })); const initial = snapshot(); mockSession.fire('session.mcp_server_status_changed', { serverName: 'local-memory', status: 'stopped' }); + mockSession.fire('session.mcp_server_status_changed', { serverName: 'computer-use', status: 'stopped' }); + mockSession.fire('session.mcp_server_status_changed', { serverName: 'github-copilot-connector-1', status: 'stopped' }); + mockSession.fire('session.mcp_server_status_changed', { serverName: 'github-mcp-server', status: 'stopped' }); const expected = [ - { name: 'local-memory', source: 'user' }, - { name: 'github', source: 'user' }, - { name: 'workspace-server', source: 'workspace' }, - { name: 'plugin-server', source: 'plugin' }, - { name: 'github-mcp-server', source: 'builtin' }, + { name: 'local-memory', source: 'user', displayName: undefined, sourcePlugin: undefined }, + { name: 'github', source: 'user', displayName: undefined, sourcePlugin: undefined }, + { name: 'workspace-server', source: 'workspace', displayName: undefined, sourcePlugin: undefined }, + { name: 'plugin-server', source: 'plugin', displayName: undefined, sourcePlugin: 'acme' }, + { name: 'github-mcp-server', source: 'builtin', displayName: undefined, sourcePlugin: undefined }, + { name: 'computer-use', source: 'builtin', displayName: undefined, sourcePlugin: 'computer-use' }, + { name: 'github-copilot-connector-1', source: 'managed', displayName: undefined, sourcePlugin: undefined }, ]; assert.deepStrictEqual({ initial, afterStatusChange: snapshot() }, { initial: expected, afterStatusChange: expected }); }); + test('declares the GitHub MCP setting only for the runtime\'s own GitHub server, not a configured server of the same name', async () => { + const { session, mockSession, waitForSignal } = await createAgentSession(disposables, { + configureMockSession: m => { + m.mcpListResult = { servers: [{ name: 'github-mcp-server', status: 'connected', source: 'builtin' }] }; + }, + }); + await waitForSignal(s => isAction(s, ActionType.SessionCustomizationUpdated)); + const setting = () => readMcpServerControllingSetting(session.topLevelMcpCustomizations().find(server => server.name === 'github-mcp-server')); + const builtin = setting(); + mockSession.fire('session.mcp_server_status_changed', { serverName: 'github-mcp-server', status: 'stopped' }); + const afterStatusChange = setting(); + + const configured: Record = {}; + for (const [kind, server] of [ + ['user', { name: 'github-mcp-server', status: 'connected', source: 'user', sourcePlugin: undefined }], + ['plugin', { name: 'github-mcp-server', status: 'connected', source: 'plugin', sourcePlugin: 'acme' }], + ['builtinPlugin', { name: 'github-mcp-server', status: 'connected', source: 'builtin', sourcePlugin: 'acme' }], + ] as const) { + const published = (signal: AgentSignal) => isAction(signal, ActionType.SessionCustomizationUpdated) + && signal.action.type === ActionType.SessionCustomizationUpdated + && signal.action.customization.type === CustomizationType.McpServer + && readMcpServerSource(signal.action.customization) === server.source + && readMcpServerSourcePlugin(signal.action.customization) === server.sourcePlugin; + mockSession.mcpListResult = { servers: [server] }; + mockSession.fire('session.mcp_servers_loaded', { servers: [] }); + await waitForSignal(published); + configured[kind] = setting(); + } + + assert.deepStrictEqual({ builtin, afterStatusChange, configured }, { + builtin: 'chat.agentHost.githubMcpServer.enabled', + afterStatusChange: 'chat.agentHost.githubMcpServer.enabled', + configured: { user: undefined, plugin: undefined, builtinPlugin: undefined }, + }); + }); + test('resolves omitted SDK sources from user configuration without reclassifying explicit or unknown sources', async () => { const { session, mockSession, waitForSignal } = await createAgentSession(disposables, { getUserMcpServerNames: async () => new Set(['local-memory', 'github', 'workspace-server', 'plugin-server', 'github-mcp-server']), @@ -21260,7 +21525,7 @@ Use the attached image as context. const userUri = URI.file(join(copilotHome ?? join('/mock-home', '.copilot'), 'mcp-config.json')).toString(); const expected = ['explicit-user', 'inferred-user', 'workspace-server', 'plugin-server', 'builtin-server', 'unknown-server'].map(name => { - const id = `mcp-top-level:copilot:test-session-1:${name}`; + const id = `mcp-top-level:copilotcli:test-session-1:${name}`; return { id, uri: name.endsWith('-user') ? userUri : id }; }); assert.deepStrictEqual({ initial, afterLifecycle: snapshot() }, { initial: expected, afterLifecycle: expected }); diff --git a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts index 802e3d32be1420..13bb7098e6e417 100644 --- a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts @@ -223,6 +223,20 @@ suite('CopilotSessionLauncher sandbox policy', () => { } for (const kind of ['create', 'resume'] as const) { + test(`${kind} exposes native events before the SDK session is returned without changing policy handling`, async () => { + const fixture = setup(kind); + const events: string[] = []; + store.add(await fixture.launcher.launch(fixture.plan, { ...testRuntime, onSessionEvent: event => events.push(event.type) })); + fixture.captured?.onEvent?.({ + id: 'title', parentId: null, timestamp: '2026-10-02T21:00:00Z', + type: 'session.title_changed', ephemeral: true, data: { title: 'Native title' }, + }); + assert.deepStrictEqual({ + events, + applied: fixture.updates.filter(update => update.sandboxConfig).map(update => update.sandboxConfig?.enabled), + }, { events: ['session.managed_settings_resolved', 'session.title_changed'], applied: [false] }); + }); + for (const [key, field] of [ ['authenticateGit', 'git'], ['authenticateGh', 'gh'], @@ -490,8 +504,8 @@ suite('synthesizeByokSessionConfig', () => { test('synthesizes deduped providers and per-model config from the active bridge', async () => { const registry = new ByokLmBridgeRegistry(); const registration = registry.register('client-1', connectionOf([ - { vendor: 'acme', id: 'claude', name: 'Acme Claude', maxContextWindowTokens: 200000, maxPromptTokens: 32000, maxOutputTokens: 4000 }, - { vendor: 'acme', id: 'gpt', name: undefined, maxContextWindowTokens: undefined }, + { vendor: 'acme', id: 'claude', name: 'Acme Claude', maxContextWindowTokens: 200000, maxPromptTokens: 32000, maxOutputTokens: 4000, supportsVision: true }, + { vendor: 'acme', id: 'gpt', name: undefined, maxContextWindowTokens: undefined, supportsVision: false }, { vendor: 'globex', id: 'llama', name: 'Globex Llama' }, ])); const proxy = countingProxy(); @@ -506,8 +520,8 @@ suite('synthesizeByokSessionConfig', () => { { name: 'globex', type: 'openai', wireApi: 'responses', baseUrl: 'http://127.0.0.1:1/v/globex', bearerToken: 'NONCE.sess-1' }, ], models: [ - { id: 'claude', provider: 'acme', name: 'Acme Claude', maxContextWindowTokens: 200000, maxPromptTokens: 32000, maxOutputTokens: 4000 }, - { id: 'gpt', provider: 'acme' }, + { id: 'claude', provider: 'acme', name: 'Acme Claude', maxContextWindowTokens: 200000, maxPromptTokens: 32000, maxOutputTokens: 4000, capabilities: { supports: { vision: true } } }, + { id: 'gpt', provider: 'acme', capabilities: { supports: { vision: false } } }, { id: 'llama', provider: 'globex', name: 'Globex Llama' }, ], }); @@ -659,7 +673,7 @@ suite('CopilotSessionLauncher BYOK proxy lifecycle', () => { return { service, get starts() { return starts; }, get disposes() { return disposes; } }; } - function createLauncher(store: DisposableStore, proxy: IByokLmProxyService, registry: IByokLmBridgeRegistry, byokModelsEnabled = true): CopilotSessionLauncher { + function createLauncher(store: DisposableStore, proxy: IByokLmProxyService, registry: IByokLmBridgeRegistry, byokModelsEnabled?: boolean): CopilotSessionLauncher { const services = new ServiceCollection(); services.set(ILogService, new NullLogService()); services.set(IByokLmProxyService, proxy); @@ -713,101 +727,113 @@ suite('CopilotSessionLauncher BYOK proxy lifecycle', () => { } }); - test('reconciles connector MCP servers through the Copilot runtime before launch completes', async () => { - const connectorCalls: string[] = []; - let connectorDisplayNames: ReadonlyMap = new Map(); - let featureFlags: Record | undefined; - const session = { - sessionId: 'connector-session', - on: () => () => { }, - disconnect: async () => { }, - rpc: { - options: { update: async () => ({ success: true }) }, - gitHubAuth: { - getStatus: async () => { - connectorCalls.push('auth'); - return { isAuthenticated: true, authType: 'token-provider' as const, host: 'github.com', login: 'octocat' }; - }, + for (const { authInfo, reconcileError } of [ + { authInfo: { type: 'token-provider' as const, host: 'https://github.com', registrationId: 'registration-1', copilotUser: { login: 'octocat' } } }, + { authInfo: { type: 'account' as const, host: 'https://github.com', login: 'octocat' } }, + { authInfo: { type: 'account' as const, host: 'https://github.com', login: 'octocat' }, reconcileError: new Error('MCP server requires authentication') }, + ]) { + test(`${reconcileError ? 'recovers connector MCP names when reconciliation requires authentication' : 'reconciles connector MCP servers through the Copilot runtime before launch completes'} (${authInfo.type})`, async () => { + const connectorCalls: string[] = []; + let connectorDisplayNames: ReadonlyMap = new Map(); + let featureFlags: Record | undefined; + const connectorStatus = { + apiVersion: 1, + availability: 'enabled' as const, + catalog: { + revision: 1, + refreshedAtMs: 1, + connectors: [{ name: 'mail', displayName: 'Work IQ Mail', status: 'connected' as const, runtimeServerIds: ['connector-mail'] }], }, - connectors: { - getCapabilities: async () => { - connectorCalls.push('capabilities'); - return { availability: 'enabled' as const }; + runtimeServers: [{ runtimeServerId: 'connector-mail', connectorName: 'mail', status: 'connected' as const }], + pendingConnections: 0, + }; + const session = { + sessionId: 'connector-session', + on: () => () => { }, + disconnect: async () => { }, + rpc: { + options: { update: async () => ({ success: true }) }, + gitHubAuth: { + getStatus: async () => { + connectorCalls.push('auth'); + return { isAuthenticated: true, authType: authInfo.type, host: 'github.com', login: 'octocat' }; + }, }, - reconcile: async (request: { accountId: string; refreshCatalog?: boolean }) => { - connectorCalls.push(`reconcile:${request.accountId}:${request.refreshCatalog === true}`); - return { - apiVersion: 1, - availability: 'enabled' as const, - catalog: { - revision: 1, - refreshedAtMs: 1, - connectors: [{ name: 'mail', displayName: 'Work IQ Mail', status: 'connected' as const, runtimeServerIds: ['connector-mail'] }], - }, - runtimeServers: [{ runtimeServerId: 'connector-mail', connectorName: 'mail', status: 'connected' as const }], - pendingConnections: 0, - }; + connectors: { + getCapabilities: async () => { + connectorCalls.push('capabilities'); + return { availability: 'enabled' as const }; + }, + getStatus: async () => { + connectorCalls.push('status'); + return connectorStatus; + }, + reconcile: async (request: { accountId: string; refreshCatalog?: boolean }) => { + connectorCalls.push(`reconcile:${request.accountId}:${request.refreshCatalog === true}`); + if (reconcileError) { + throw reconcileError; + } + return connectorStatus; + }, }, }, - }, - } as unknown as CopilotSession; - const client = { - rpc: { - account: { - getAllUsers: async () => { - connectorCalls.push('accounts'); - return [{ - authInfo: { - type: 'token-provider' as const, - host: 'https://github.com', - registrationId: 'registration-1', - copilotUser: { login: 'octocat' }, - }, - selectionId: 'account-1', - }]; + } as unknown as CopilotSession; + const client = { + rpc: { + account: { + getAllUsers: async () => { + connectorCalls.push('accounts'); + return [{ + authInfo: { ...authInfo, login: 'another-user', copilotUser: { login: 'another-user' } }, + selectionId: 'another-account', + }, { + authInfo, + selectionId: 'account-1', + }]; + }, }, }, - }, - createSession: async (config: Parameters[0]) => { - reportManagedSettings(config); - featureFlags = config.featureFlags; - return session; - }, - resumeSession: async () => { throw new Error('Unexpected resume'); }, - } as unknown as CopilotClient; - const launcher = createTestLauncher(undefined, { [AgentHostMcpConnectorsEnabledConfigKey]: true }); - const plan: CopilotSessionLaunchPlan = { - kind: 'create', - client, - extensionSdkPath: '/copilot-sdk', - sessionId: 'connector-session', - workingDirectory: testWorkingDirectory, - resolvedAgentName: undefined, - snapshot: { tools: [], plugins: [], mcpServers: {} }, - activeClientToolSet: new ActiveClientToolSet(), - shellManager: undefined, - githubCredentials: CopilotGitHubSessionCredentials.fromToken('connector-token'), - model: undefined, - }; + createSession: async (config: Parameters[0]) => { + reportManagedSettings(config); + featureFlags = config.featureFlags; + return session; + }, + resumeSession: async () => { throw new Error('Unexpected resume'); }, + } as unknown as CopilotClient; + const launcher = createTestLauncher(undefined, { [AgentHostMcpConnectorsEnabledConfigKey]: true }); + const plan: CopilotSessionLaunchPlan = { + kind: 'create', + client, + extensionSdkPath: '/copilot-sdk', + sessionId: 'connector-session', + workingDirectory: testWorkingDirectory, + resolvedAgentName: undefined, + snapshot: { tools: [], plugins: [], mcpServers: {} }, + activeClientToolSet: new ActiveClientToolSet(), + shellManager: undefined, + githubCredentials: CopilotGitHubSessionCredentials.fromToken('connector-token'), + model: undefined, + }; - const launched = await launcher.launch(plan, { - ...testRuntime, - setMcpServerDisplayNames: displayNames => connectorDisplayNames = new Map(displayNames), - }); - try { - assert.deepStrictEqual({ - featureFlags, - connectorCalls, - connectorDisplayName: connectorDisplayNames.get('connector-mail'), - }, { - featureFlags: { CONNECTORS: true, TGREP: false, CONTENT_EXCLUSION: true, MANAGED_MCP_SERVERS: true }, - connectorCalls: ['capabilities', 'auth', 'accounts', 'reconcile:account-1:true'], - connectorDisplayName: 'Work IQ Mail', + const launched = await launcher.launch(plan, { + ...testRuntime, + setMcpServerDisplayNames: displayNames => connectorDisplayNames = new Map(displayNames), }); - } finally { - launched.dispose(); - } - }); + try { + assert.deepStrictEqual({ + featureFlags, + connectorCalls, + connectorDisplayName: connectorDisplayNames.get('connector-mail'), + }, { + featureFlags: { CONNECTORS: true, TGREP: false, CONTENT_EXCLUSION: true, MANAGED_MCP_SERVERS: true }, + connectorCalls: ['capabilities', 'auth', 'accounts', 'reconcile:account-1:true', ...(reconcileError ? ['status'] : [])], + connectorDisplayName: 'Work IQ Mail', + }); + } finally { + launched.dispose(); + } + }); + } }); diff --git a/src/vs/platform/agentHost/test/node/copilotSessionMetadataEvents.test.ts b/src/vs/platform/agentHost/test/node/copilotSessionMetadataEvents.test.ts new file mode 100644 index 00000000000000..eb6b40eaf16826 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/copilotSessionMetadataEvents.test.ts @@ -0,0 +1,111 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { mkdir, rm, writeFile } from 'fs/promises'; +import { join } from '../../../../base/common/path.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { URI } from '../../../../base/common/uri.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { readCopilotSessionMetadataEvents, toCopilotSessionMetadataEvent } from '../../node/copilot/copilotSessionMetadataEvents.js'; + +const chat = URI.parse('ahp-session:/native/chat'); +const title = { id: 'title', type: 'session.title_changed', timestamp: '2026-10-02T21:00:00Z', data: { title: 'Native title' } }; + +suite('Copilot session metadata events', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('admits genuine root metadata verbatim but excludes content, auth, config, deltas and subagents', () => { + const rejected = [ + 'assistant.message', 'assistant.reasoning', 'assistant.message_delta', 'assistant.reasoning_delta', + 'assistant.tool_call_delta', 'assistant.streaming_delta', 'mcp.oauth_required', + 'session.managed_settings_resolved', 'session.permissions_changed', 'new.unknown', + 'session.shutdown', + ].map(type => toCopilotSessionMetadataEvent(chat, { ...title, type, data: { token: 'secret' } })); + rejected.push(toCopilotSessionMetadataEvent(chat, { ...title, agentId: 'worker' })); + assert.deepStrictEqual({ + admitted: toCopilotSessionMetadataEvent(chat, title), + rejected, + }, { admitted: { ...title, chat, persisted: true }, rejected: rejected.map(() => undefined) }); + }); + + test('rejects malformed metadata rather than forwarding an untyped wire shape', () => { + const values = [null, {}, { ...title, data: null }, { ...title, id: '' }, { ...title, timestamp: 'not a time' }]; + assert.deepStrictEqual(values.map(value => toCopilotSessionMetadataEvent(chat, value)), values.map(() => undefined)); + }); + + test('replays genuine journal metadata and ignores an unfinished append', async () => { + const path = join(process.cwd(), '.build', `mc-sdk-journal-${generateUuid()}`); + await mkdir(path); + try { + const journal = join(path, 'events.jsonl'); + await writeFile(journal, `${JSON.stringify(title)}\n${JSON.stringify({ ...title, type: 'assistant.message' })}\n{"unfinished":`); + const events = []; + for await (const event of readCopilotSessionMetadataEvents(chat, journal, CancellationToken.None)) { + events.push(event); + } + assert.deepStrictEqual(events, [{ ...title, chat, persisted: true }]); + await writeFile(journal, '{"malformed":\n'); + const losses = []; + for await (const event of readCopilotSessionMetadataEvents(chat, journal, CancellationToken.None)) { + losses.push({ type: event.type, data: event.data }); + } + assert.deepStrictEqual(losses, [{ type: 'session.events_truncated', data: { dropped_count: 1 } }]); + } finally { + await rm(path, { recursive: true }); + } + }); + + test('bounds line allocations and fences cancellation', async () => { + const path = join(process.cwd(), '.build', `mc-sdk-journal-${generateUuid()}`); + await mkdir(path); + try { + const journal = join(path, 'events.jsonl'); + await writeFile(journal, `${'x'.repeat(10 * 1024 * 1024)}\n${JSON.stringify(title)}\n`); + const events = []; + for await (const event of readCopilotSessionMetadataEvents(chat, journal, CancellationToken.None)) { + events.push({ type: event.type, data: event.data }); + } + assert.deepStrictEqual(events, [ + { type: 'session.events_truncated', data: { dropped_count: 1 } }, + { type: 'session.title_changed', data: title.data }, + ]); + const cancellation = store.add(new CancellationTokenSource()); + cancellation.cancel(); + await writeFile(journal, `${JSON.stringify(title)}\n`); + await assert.rejects(async () => { + for await (const _event of readCopilotSessionMetadataEvents(chat, journal, cancellation.token)) { } + }, /Canceled/); + } finally { + await rm(path, { recursive: true }); + } + }); + + test('initial reconciliation selects latest genuine facts, while an acknowledged cursor replays only its new tail', async () => { + const path = join(process.cwd(), '.build', `mc-sdk-journal-${generateUuid()}`); + await mkdir(path); + try { + const journal = join(path, 'events.jsonl'); + const events = Array.from({ length: 15_000 }, (_, index) => ({ + ...title, id: `event-${index}`, type: 'assistant.turn_end', data: { turnId: String(index) }, + })); + await writeFile(journal, `${events.map(event => JSON.stringify(event)).join('\n')}\n${JSON.stringify(title)}\n`); + const initial = []; + for await (const event of readCopilotSessionMetadataEvents(chat, journal, CancellationToken.None)) { + initial.push(event.id); + } + const tail = []; + for await (const event of readCopilotSessionMetadataEvents(chat, journal, CancellationToken.None, 'event-14998')) { + tail.push(event.id); + } + assert.deepStrictEqual({ initial, tail }, { + initial: ['event-14999', 'title'], tail: ['event-14998', 'event-14999', 'title'], + }); + } finally { + await rm(path, { recursive: true }); + } + }); +}); diff --git a/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts b/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts index aad225224fe82e..2bebcf521d6bac 100644 --- a/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts @@ -8,7 +8,7 @@ import type { PermissionRequest } from '@github/copilot-sdk'; import * as marked from '../../../../base/common/marked/marked.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { getEditFilePath, getEditFilePaths, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellIntention, getShellLanguage, getStreamingInvocationMessage, getSubagentMetadata, getTaskCompleteMarkdown, getToolDisplayName, getToolInputString, getToolKind, getToolMarkdownContent, isEditTool, isHiddenTool, isMarkdownRenderedTool, synthesizeSkillToolCall } from '../../node/copilot/copilotToolDisplay.js'; +import { getEditFilePath, getEditFilePaths, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getSdkImageGenerationMetadata, getShellIntention, getShellLanguage, getStreamingInvocationMessage, getSubagentMetadata, getTaskCompleteMarkdown, getToolDisplayName, getToolInputString, getToolKind, getToolMarkdownContent, isEditTool, isHiddenTool, isMarkdownRenderedTool, synthesizeSkillToolCall } from '../../node/copilot/copilotToolDisplay.js'; type CopilotShellPermissionRequest = Extract; type CopilotCustomToolPermissionRequest = Extract; @@ -113,6 +113,30 @@ suite('copilotToolDisplay — friendly tool names', () => { assert.strictEqual(getToolDisplayName('some_new_tool'), 'some_new_tool'); }); + test('uses requested image identity rather than the conversation model', () => { + const metadata = getSdkImageGenerationMetadata({ + model: 'claude-sonnet-5', + structuredContent: { imageGeneration: { requestedModel: { id: 'image-preview', name: 'Image Preview' } } }, + }); + assert.deepStrictEqual({ + metadata, + completed: getPastTenseMessage('image_generation', 'Generate Image', undefined, true, undefined, undefined, undefined, metadata), + idFallback: getPastTenseMessage('image_generation', 'Generate Image', undefined, true, undefined, undefined, undefined, { requestedModel: { id: 'image-preview' } }), + legacy: getPastTenseMessage('image_generation', 'Generate Image', undefined, true), + failed: getPastTenseMessage('image_generation', 'Generate Image', undefined, false, undefined, undefined, undefined, metadata), + unrelated: getSdkImageGenerationMetadata({ model: 'gpt-5.5' }), + malformed: getSdkImageGenerationMetadata({ structuredContent: { imageGeneration: { requestedModel: { name: 'Not an identity' } } } }), + }, { + metadata: { requestedModel: { id: 'image-preview', name: 'Image Preview' } }, + completed: 'Generated image with Image Preview', + idFallback: 'Generated image with image-preview', + legacy: 'Generated image', + failed: '"Generate Image" failed', + unrelated: undefined, + malformed: undefined, + }); + }); + test('prefers canonical tool titles and falls back to original MCP tool names', () => { const toolName = 'io-github-github-github-mcp-server-issue_read'; assert.deepStrictEqual({ @@ -265,8 +289,8 @@ suite('getPermissionDisplay — server tool confirmation', () => { isolation: false, })), { - confirmationTitle: 'Continue in app?', - invocationMessage: 'Continue this session in /workspace/app and make changes directly in that folder?', + confirmationTitle: 'Change Workspace to app?', + invocationMessage: 'Change this chat\'s workspace to /workspace/app and make changes directly in that folder? Other chats keep their workspaces.', toolInput: undefined, permissionKind: 'custom-tool', permissionPath: undefined, diff --git a/src/vs/platform/agentHost/test/node/devContainerAgentHostService.test.ts b/src/vs/platform/agentHost/test/node/devContainerAgentHostService.test.ts index 82526cc979b6ba..235059b550eaae 100644 --- a/src/vs/platform/agentHost/test/node/devContainerAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/node/devContainerAgentHostService.test.ts @@ -25,6 +25,7 @@ import { TestConfigurationService } from '../../../configuration/test/common/tes import { INativeEnvironmentService } from '../../../environment/common/environment.js'; import { VSCODE_REMOTE_CONTAINERS_SESSION_ENV } from '../../common/devContainerAgentHost.js'; import { IRequestService } from '../../../request/common/request.js'; +import { IGitHubService } from '../../../github/common/githubService.js'; import { URI } from '../../../../base/common/uri.js'; import { DevContainerAgentHostMainService, getDevContainerCliPath, getDevContainerExecArgs, IDevContainerRelay, parseDevContainerMounts, parseDevContainerUpResult, waitForDevContainerRelayConnection } from '../../node/devContainerAgentHostService.js'; import { ISshExec, shellEscape } from '../../node/sshRemoteAgentHostHelpers.js'; @@ -129,6 +130,7 @@ class TestDevContainerAgentHostMainService extends DevContainerAgentHostMainServ return [..._certificates]; } }(), + new class extends mock() { }(), ); } diff --git a/src/vs/platform/agentHost/test/node/devContainerSamples.test.ts b/src/vs/platform/agentHost/test/node/devContainerSamples.test.ts index cdb7fb4fe99a30..fccaaab94d10f2 100644 --- a/src/vs/platform/agentHost/test/node/devContainerSamples.test.ts +++ b/src/vs/platform/agentHost/test/node/devContainerSamples.test.ts @@ -7,13 +7,18 @@ import assert from 'assert'; import { createHash } from 'crypto'; import { mkdtemp, readFile, rm } from 'fs/promises'; import { tmpdir } from 'os'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Event } from '../../../../base/common/event.js'; import { join } from '../../../../base/common/path.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { GitHubService } from '../../../github/common/githubService.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; import { devContainerSamples, devContainerSampleUri, findDevContainerSample, getDevContainerSampleUrl } from '../../common/devContainerSamples.js'; import { getDevContainerSampleLabels, getDevContainerSampleVolumeName, parseDevContainerSampleConfiguration, prepareDevContainerSample } from '../../node/devContainerSamples.js'; suite('Dev Container samples', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const store = ensureNoDisposablesAreLeakedInTestSuite(); const sample = devContainerSamples[2]; const repositoryPath = 'https://github.com/Microsoft/vscode-remote-try-node'; const folder = 'vscode-remote-try-node'; @@ -85,15 +90,28 @@ suite('Dev Container samples', () => { const calls: string[] = []; const fetchedUrls: string[] = []; const cloneUsesLowercaseUrl: boolean[] = []; + const cloneUsesPinnedCommit: boolean[] = []; const volumeName = getDevContainerSampleVolumeName(sample, []); const config = { image: 'sample-image', postCreateCommand: 'npm install' }; + const service = store.add(new GitHubService({ + credentialProvider: { onDidChange: Event.None, getToken: () => { throw new Error('Samples must remain anonymous'); } }, + fetch: async input => { + const url = String(input); + fetchedUrls.push(url); + calls.push(url.includes('/commits/') ? 'commit' : 'config'); + return new Response(JSON.stringify(url.includes('/commits/') ? { sha: commit } : config)); + }, + }, new NullLogService(), NullTelemetryService)); let existing = false; const commands: Parameters[2] = { onContainerStarted: id => calls.push(`started ${id}`), - fetch: async url => { - fetchedUrls.push(url); - calls.push(url.includes('/commits/') ? 'commit' : 'config'); - return url.includes('/commits/') ? JSON.stringify({ sha: commit }) : JSON.stringify(config); + readSource: async () => { + const client = store.add(service.acquireAnonymousClient()); + try { + return await client.object.getFile('microsoft', folder, '.devcontainer/devcontainer.json', CancellationToken.None); + } finally { + client.dispose(); + } }, docker: async args => { let stdout: string; @@ -107,6 +125,7 @@ suite('Dev Container samples', () => { calls.push(clone ? 'clone' : 'read config'); if (clone) { cloneUsesLowercaseUrl.push(args.at(-1)!.includes('https://github.com/microsoft/vscode-remote-try-node')); + cloneUsesPinnedCommit.push(args.at(-1)!.includes(`checkout -B "$branch" '${commit}'`)); } stdout = clone ? '' : JSON.stringify(config); } @@ -130,14 +149,18 @@ suite('Dev Container samples', () => { calls, fetchedUrls, cloneUsesLowercaseUrl, + cloneUsesPinnedCommit, sameIdentity: first.repository.volumeName === second.repository.volumeName, + source: JSON.parse(await readFile(join(cacheDirectory, 'source.json'), 'utf8')), config: JSON.parse(await readFile(join(cacheDirectory, 'devcontainer.json'), 'utf8')), }, { calls: ['commit', 'config', 'volume ls', 'volume create', 'volume inspect', 'up (skip hooks)', 'started container', 'clone', 'read config', 'hooks', 'volume ls', 'volume inspect', 'up (skip hooks)', 'started container', 'clone', 'read config', 'hooks'], fetchedUrls: [`https://api.github.com/repos/microsoft/${folder}/commits/HEAD`, `https://raw.githubusercontent.com/microsoft/${folder}/${commit}/.devcontainer/devcontainer.json`], cloneUsesLowercaseUrl: [true, true], + cloneUsesPinnedCommit: [true, true], sameIdentity: true, + source: { commit, content: JSON.stringify(config) }, config: { ...config, workspaceFolder: `/workspaces/${folder}`, @@ -150,7 +173,7 @@ suite('Dev Container samples', () => { test('unsupported configuration fails before creating a volume or container', async () => { await assert.rejects(prepareDevContainerSample(sample, cacheDirectory, { onContainerStarted: () => { throw new Error('No container should be registered'); }, - fetch: async url => url.includes('/commits/') ? JSON.stringify({ sha: commit }) : '{"image":"image","features":{"java":{}}}', + readSource: async () => ({ commitSha: commit, content: '{"image":"image","features":{"java":{}}}' }), docker: async () => { throw new Error('Docker must not run'); }, devcontainer: async () => { throw new Error('Dev Container CLI must not run'); }, }), /requires an image build/); @@ -161,7 +184,7 @@ suite('Dev Container samples', () => { let lifecycleCalls = 0; const preparation = prepareDevContainerSample(sample, cacheDirectory, { onContainerStarted: () => { }, - fetch: async url => url.includes('/commits/') ? JSON.stringify({ sha: commit }) : '{"image":"old-image"}', + readSource: async () => ({ commitSha: commit, content: '{"image":"old-image"}' }), docker: async args => ({ stdout: args[0] === 'inspect' ? JSON.stringify(rebuilt ? 'new-image' : 'old-image') : args[1] === 'inspect' ? JSON.stringify({ 'vsch.local.repository': repositoryPath }) @@ -193,7 +216,7 @@ suite('Dev Container samples', () => { const started: string[] = []; await assert.rejects(prepareDevContainerSample(sample, cacheDirectory, { onContainerStarted: id => started.push(id), - fetch: async url => url.includes('/commits/') ? JSON.stringify({ sha: commit }) : '{"image":"sample-image"}', + readSource: async () => ({ commitSha: commit, content: '{"image":"sample-image"}' }), docker: async args => { const isClone = args.includes('/bin/sh'); return { @@ -218,7 +241,7 @@ suite('Dev Container samples', () => { const started: string[] = []; await assert.rejects(prepareDevContainerSample(sample, cacheDirectory, { onContainerStarted: id => started.push(id), - fetch: async url => url.includes('/commits/') ? JSON.stringify({ sha: commit }) : '{"image":"sample-image"}', + readSource: async () => ({ commitSha: commit, content: '{"image":"sample-image"}' }), docker: async args => ({ stdout: args[1] === 'inspect' ? JSON.stringify({ 'vsch.local.repository': repositoryPath }) : '', stderr: '', code: 0, 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 cfe6b962a26e46..019ee67547cd12 100644 --- a/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md +++ b/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md @@ -16,6 +16,66 @@ When a valid E2E scenario exposes a gap: Capability skips are tracked separately from suspected bugs. A provider that does not advertise a capability is expected to skip positive-path tests for that capability. +### Copilot managed identity denial retains an inherited account resource attribute + +An administrator can disable identity capture while the runtime inherits an explicit `user.name` resource attribute. The native runtime removes `process.user.name` and `host.name`, but the inherited `user.name` still reaches the managed collector. This scenario concerns native Copilot export, not the separate Agent Host metadata pipeline. + +- Test: `managed identity denial removes inherited identity from native spans`. +- Scope: Copilot runtime `1.0.92-4`, strict replay on all platforms. +- Expected: managed `telemetry.capture.identity=false` removes all three identity attributes from native spans and events despite local opt-in and inherited resource attributes. +- Observed: three native spans retain `user.name=synthetic-account`. +- Tracking: the [bundled runtime's identity-resource predicate](https://github.com/github/copilot-agent-runtime/blob/f4385f4f118c567aa0178776aeb45e296e9e6733/src/runtime/src/otel/sdk.rs#L1945) includes only `process.user.name` and `host.name`. Stephen Toub introduced this predicate on September 14, 2026, in [github/copilot-agent-runtime#19723](https://github.com/github/copilot-agent-runtime/pull/19723). No upstream issue was filed by this task. +- Gate: a strict expected-failure marker accepts only the identity-redaction assertion. An unexpected pass fails and requires removing the marker. Recording skips the case to preserve its complete existing fixture. +- Reproduce: + + ```bash + ./scripts/test-integration.sh --run src/vs/platform/agentHost/test/node/e2e/providers/copilotOtelAgentHostE2E.integrationTest.ts \ + --grep "managed identity denial" + ``` + +### Historical binary Git changeset content loses non-UTF-8 bytes + +A user can inspect both sides of an agent's binary-file change through the changeset's content references. The current working file preserves its bytes, but reading the historical Git-blob reference converts invalid UTF-8 bytes into replacement characters, so binary content cannot be recovered exactly. + +- Test: `regression coverage: binary changeset references preserve both byte sequences`. +- Scope: conformance reference host on all platforms; no model traffic. +- Expected: the before and after content references preserve the exact binary byte sequences, using the encoding reported by `resourceRead`. +- Observed: historical bytes containing `0xff` and `0xfe` are returned as UTF-8 replacement characters; the current-file reference still returns the correct bytes. +- Source evidence: `AgentService._fetchGitBlobContent` converts the Git buffer to a string and always reports UTF-8. +- Gate: a strict expected-failure marker accepts only the historical-byte assertion. Current-file corruption, unavailable content references, setup/teardown failures, and an unexpected pass still fail the test. +- Reproduce: + + ```bash + ./scripts/test-integration.sh --run src/vs/platform/agentHost/test/node/e2e/conformance/agentHostConformance.integrationTest.ts \ + --grep "regression coverage: binary changeset references" + ``` + +### Codex rejects a schema-valid mixed-content MCP tool response + +An MCP application can call a tool that returns ordered text, an image, embedded text, and embedded binary content. Copilot forwards this schema-valid result, but the bundled Codex provider rejects it while decoding the response, preventing the application from consuming the tool output. + +- Test: `MCP side channel: regression coverage: preserves ordered text image and embedded resource tool content`. +- Scope: bundled Codex `0.157.0`; this precise mixed-content response. +- Expected: preserve every content block, annotation, byte encoding, and order. +- Observed: `JsonRpcError: tool call failed for side_channel/echo: Unexpected response type`. The current evidence does not isolate which block Codex rejects. +- Prerequisites checked independently: MCP schema validation, a fully drained warm-up turn, the exact ready advertised channel, and a real server witness confirming the requested `echo` call. +- Gate: Codex-only strict expected failure accepts only the exact decoding error and its optional stack frames. Other errors, content mismatches, and an unexpected pass fail. Default recording skips only this Codex variant and preserves its complete one-turn warm-up fixture. +- Reevaluate after a Codex upgrade by running: + + ```bash + ./scripts/test-integration.sh --run src/vs/platform/agentHost/test/node/e2e/providers/codexAgentHostE2E.integrationTest.ts \ + --grep "MCP side channel: regression coverage: preserves ordered text image" + ``` + +### Expanded provider history coverage retains existing platform limitations + +The new `regression coverage:` history and fork scenarios exercise cold resume, completed tool pairing, peer isolation, and selected-turn fork boundaries. They retain the existing provider limitations rather than treating recorded assistant text as proof of correct restoration. + +- Copilot Windows: cold tool-history, peer, and resumed-fork variants use the existing provider-session restart gate described under [Copilot provider sessions can disappear across a Windows host restart](#copilot-provider-sessions-can-disappear-across-a-windows-host-restart). Ordered text and identical-prompt resume checks remain enabled. +- Claude: the four provider-backed selected-turn fork variants retain the existing `supportsChatForkE2E` gate described under [Claude provider-context fork](#claude-provider-context-fork); ordinary cold history and peer restoration remain enabled. +- Recording does not bypass these known unsupported paths by default. Reevaluate the gates using the same actual tool/result and provider-bound history assertions when the underlying provider fixes are bundled. +- Focused reproduction: use `--grep "regression coverage:"` with the affected provider's E2E entrypoint; the individual history/fork test titles identify the requested boundary. + ### Codex Linux startup races in shared empty workspaces Starting two Codex chats in the same empty workspace can fail before the first prompt runs. With Codex 0.153.0 on Linux, initialization intermittently fails on a protected metadata directory that is missing by the time bubblewrap mounts it. This also reproduces with concurrent `thread/start` calls directly to the bundled app-server, without Agent Host. diff --git a/src/vs/platform/agentHost/test/node/e2e/README.md b/src/vs/platform/agentHost/test/node/e2e/README.md index 184dec9ddea9bf..c6ffca25eefe64 100644 --- a/src/vs/platform/agentHost/test/node/e2e/README.md +++ b/src/vs/platform/agentHost/test/node/e2e/README.md @@ -111,6 +111,7 @@ The residual case is `providerHostOnlyTest(...)`: per-provider, but no model tra | `suites/clientHostedFilesystemSuite.ts` | Host-to-client `resource*` operations against client-hosted files. | | `suites/workingDirectoriesSuite.ts` | Multi-root peer scoping, delegated folders, additional worktree ownership, and workspace persistence. | | `suites/mcpSideChannelSuite.ts` | Real MCP application requests tunneled over AHP, including resources, tool results, concurrent callers, and lifecycle recovery. | +| `suites/terminalResilienceSuite.ts` | Client-owned real PTYs: working directory, UTF-8 input, resizing, fragmented output, independent processes, and retained output across subscription changes. | | `suites/providerCheckpointSuite.ts` | Provider-executed edits and historical Git checkpoint comparisons, including index preservation and restart. | | `suites/providerErrorSuite.ts` | Endpoint-scoped model API failures, provider error classification, retries, and subsequent-turn recovery. | | `harness/` | Record/replay, AHP snapshots, shared turn drivers, and server lifecycle. | @@ -137,6 +138,14 @@ Automation lifecycle coverage uses manual-only definitions: provider-unavailable The MCP side-channel scenarios use the `channel` advertised by a real ready server, never a synthesized implementation URI. Copilot and Codex support this surface; Claude does not. Codex additionally exposes resource and template inventories, while Copilot supports stop/start. A recorded no-tool turn materializes the provider; side-channel calls then exercise the actual MCP process without model requests. +The expanded side-channel cases cover structured results, ordered content/resource encodings, empty results, concurrent error isolation, resource retry, and catalog additions/removals/schema changes. Catalog readiness is established by the actual `tools/list` result, not by a model-facing tool-change notification that need not fire for an idle MCP Apps request. + +Live client-tool coverage updates a materialized Copilot session over AHP and checks real execution plus the provider-bound request. Removal assertions project the effective Anthropic inventory, including structured addition/removal blocks, rather than assuming a cache-preserving runtime must rewrite its baseline tool array. + +Terminal resilience assertions compare filesystem identity rather than Windows short/long path spelling, render VT scrollback through a headless terminal rather than counting ConPTY repaint bytes, and send Unicode to an already-running child rather than relying on a shell editor's locale. Claude history-edit fixtures perform the required `Read` before `Edit`, including after cold resume; replayed success text cannot substitute for that tool prerequisite. + +The `regression coverage:` history cases inspect actual provider-bound continued context after cold resume and selected-turn forks, with live file-edit/result pairing and independent peer histories. They do not establish native overlapping disposal, interrupted parallel completion, or strict-transcript opt-in prerequisites that AHP does not expose. + Historical checkpoint comparisons use completed provider turns rather than bang commands. Per-turn subscriptions currently select the file-edit tracker, which cannot see shell edits; compare-turn subscriptions use Git checkpoints. Checkpoint capture is asynchronous after turn completion, so these historical scenarios finish a subsequent no-tool turn before comparing earlier turns. Seed staged user changes before the baseline turn, and use an ignored execution witness when an edit-and-restore scenario intentionally has no final diff. Detached-worktree include-file tests cover wholly ignored and partially selected directories, overlapping globs, binary contents, and collisions with files or directories tracked on another branch. They use unstarted sessions and explicitly delete their handles, avoiding the background Git work associated with model-backed worktree disposal. @@ -245,6 +254,10 @@ fetches server settings afresh and retains the production 4.5-second overall and 3.5-second query deadlines. Cold-start deadline behavior is covered by the Copilot agent unit tests. +The managed-telemetry suite asserts actual session-correlated native spans at loopback collectors. It covers managed capture restrictions and fine-grained overrides, exporter/resource/protocol precedence, per-session collector changes, policy removal, reenablement, and revoked capture on cold resume. Negative export assertions drain the host before inspecting the final collected spans. Runtime-managed telemetry and the Agent Host's own metadata pipeline are separate; native policy assertions must not treat host-produced metadata as native Copilot export. + +The managed-permissions AHP suite sends real client policy contributions rather than calling SDK tools directly. It checks native read/write asks under Allow All and Assisted approval, one-time confirmation, denied side effects, unconditional managed denials, live updates to default and peer chats, and current contributions on cold resume. Successful file mutations and real tool results are the oracles; replayed assistant responses are not proof of enforcement. + Provider availability: - **Copilot** (`copilotcli`) — always enabled (the CLI is a dev dependency). @@ -262,8 +275,10 @@ Skip these scenarios while recording so a known early failure cannot overwrite a complete fixture with a partial recording. Remove that recording skip together with the marker when the upstream fix is adopted. -The managed-telemetry no-restart scenario runs this way by default. Do not -replace its marker with a permanent negative assertion. +The native inherited-identity redaction scenario runs this way by default. +Its expected-failure marker remains accountable in `KNOWN_ISSUES.md`; the +managed-telemetry no-restart scenario passes normally with runtime `1.0.92-4`. +Do not replace an expected-failure marker with a permanent negative assertion. If a recognized failure prevents later model turns, pass `{ allowUnconsumedResponses: true }` to the lease's replay verification at @@ -622,6 +637,10 @@ You're accidentally in record mode (`AGENT_HOST_REPLAY_RECORD` set) without a to In replay one server serves every test (see [Server lifecycle](#server-lifecycle)), so a test that returns **mid-turn** leaks: the SDK's continuation call fires after the fixture is swapped and lands in a later test's window as an unrecorded call (a `POST /v1/messages` / `POST /responses` cache miss, usually attributed to the *next* test's teardown). Fix the culprit — the test that returned mid-turn — by draining its turn to `turnComplete` before it ends. (Verify by running the suspected test alone via `--grep`, which gives it a clean one-test server; if it passes alone but fails after a sibling, that's the leak.) +### Read or archive state is lost after a graceful host restart + +Check the logs from both sides of the restart for storage load errors and failed shutdown drains. A `root/sessionSummaryChanged` notification precedes background catalog synchronization; graceful shutdown must drain those writes even if global storage or another persistence flush fails. Host-owned JSON storage uses atomic replacement so an interrupted write cannot leave the next host with a truncated file. Keep the restart assertions intact: sleeping after the notification would hide a persistence failure rather than fix it. + ### CI infra flakes (not your code) Sysroot/asset download `429: Too Many Requests`, network resets, etc. are infrastructure, not test failures — re-run the failed job. diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-regression-coverage-an-unstarted-peer-can-execute-its-first-tool-turn-after-restart.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-regression-coverage-an-unstarted-peer-can-execute-its-first-tool-turn-after-restart.yaml new file mode 100644 index 00000000000000..03c86038a743ab --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-regression-coverage-an-unstarted-peer-can-execute-its-first-tool-turn-after-restart.yaml @@ -0,0 +1,96 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Remember DEFAULT_NOT_IN_PEER. Reply exactly "READY". + response: + content: READY + stopReason: end_turn + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Remember SIBLING_NOT_IN_PEER. Reply exactly "READY". + response: + content: READY + stopReason: end_turn + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Read first-peer.txt with Read, then use Edit exactly once to replace FIRST_BEFORE with FIRST_AFTER. Do not search for files or run a shell command. Then reply exactly "EDIT_DONE". + response: + content: + - type: tool_use + id: toolcall_0 + name: Read + input: + file_path: ${workdir}/first-peer.txt + stopReason: tool_use + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Read first-peer.txt with Read, then use Edit exactly once to replace FIRST_BEFORE with FIRST_AFTER. Do not search for files or run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: + - type: tool_use + name: Read + input: + file_path: ${workdir}/first-peer.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: "1\tFIRST_BEFORE\n2" + response: + content: + - type: tool_use + id: toolcall_1 + name: Edit + input: + file_path: ${workdir}/first-peer.txt + old_string: FIRST_BEFORE + new_string: FIRST_AFTER + stopReason: tool_use + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Read first-peer.txt with Read, then use Edit exactly once to replace FIRST_BEFORE with FIRST_AFTER. Do not search for files or run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: + - type: tool_use + name: Read + input: + file_path: ${workdir}/first-peer.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: "1\tFIRST_BEFORE\n2" + - role: assistant + content: + - type: tool_use + name: Edit + input: + replace_all: false + file_path: ${workdir}/first-peer.txt + old_string: FIRST_BEFORE + new_string: FIRST_AFTER + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: The file ${workdir}/first-peer.txt has been updated successfully. (file state is current in your context — no need to Read it back) + response: + content: EDIT_DONE + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-regression-coverage-catalog-refresh-does-not-promote-restored-peer-backings-to-sessions.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-regression-coverage-catalog-refresh-does-not-promote-restored-peer-backings-to-sessions.yaml new file mode 100644 index 00000000000000..06a071ae066d76 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-regression-coverage-catalog-refresh-does-not-promote-restored-peer-backings-to-sessions.yaml @@ -0,0 +1,37 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Reply exactly "CATALOG_READY". + response: + content: CATALOG_READY + stopReason: end_turn + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Remember CATALOG_PEER_ONLY. Reply exactly "PEER_READY". + response: + content: PEER_READY + stopReason: end_turn + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Remember CATALOG_PEER_ONLY. Reply exactly "PEER_READY". + - role: assistant + content: + - type: thinking + - type: text + text: PEER_READY + - role: user + content: Reply exactly "PEER_CONTINUED". Do not use tools. + response: + content: PEER_CONTINUED + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-regression-coverage-cold-restored-peers-retain-independent-provider-contexts.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-regression-coverage-cold-restored-peers-retain-independent-provider-contexts.yaml new file mode 100644 index 00000000000000..763e5137a9c33b --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-regression-coverage-cold-restored-peers-retain-independent-provider-contexts.yaml @@ -0,0 +1,56 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Reply exactly "DEFAULT_READY". + response: + content: DEFAULT_READY + stopReason: end_turn + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Remember PEER_RED_ONLY. Reply exactly "RED_READY". + response: + content: RED_READY + stopReason: end_turn + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Remember PEER_BLUE_ONLY. Reply exactly "BLUE_READY". + response: + content: BLUE_READY + stopReason: end_turn + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Remember PEER_BLUE_ONLY. Reply exactly "BLUE_READY". + - role: assistant + content: BLUE_READY + - role: user + content: Reply with the code word I gave this chat. Do not use tools. + response: + content: PEER_BLUE_ONLY + stopReason: end_turn + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Remember PEER_RED_ONLY. Reply exactly "RED_READY". + - role: assistant + content: RED_READY + - role: user + content: Reply with the code word I gave this chat. Do not use tools. + response: + content: PEER_RED_ONLY + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-regression-coverage-cold-resume-retains-ordered-user-and-assistant-context.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-regression-coverage-cold-resume-retains-ordered-user-and-assistant-context.yaml new file mode 100644 index 00000000000000..0d80e63f95d70f --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-regression-coverage-cold-resume-retains-ordered-user-and-assistant-context.yaml @@ -0,0 +1,42 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Remember the first entry ORDERED_ALPHA. Reply exactly "ALPHA_READY". + response: + content: ALPHA_READY + stopReason: end_turn + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Remember the first entry ORDERED_ALPHA. Reply exactly "ALPHA_READY". + - role: assistant + content: ALPHA_READY + - role: user + content: Remember the second entry ORDERED_BETA without replacing the first. Reply exactly "BETA_READY". + response: + content: BETA_READY + stopReason: end_turn + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Remember the first entry ORDERED_ALPHA. Reply exactly "ALPHA_READY". + - role: assistant + content: ALPHA_READY + - role: user + content: Remember the second entry ORDERED_BETA without replacing the first. Reply exactly "BETA_READY". + - role: assistant + content: BETA_READY + - role: user + content: Reply with both entries in the order I gave them. Do not use tools. + response: + content: ORDERED_ALPHA, ORDERED_BETA + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-regression-coverage-completed-file-edit-remains-paired-with-its-result-after-resume.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-regression-coverage-completed-file-edit-remains-paired-with-its-result-after-resume.yaml new file mode 100644 index 00000000000000..6058dbd5fed3f5 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-regression-coverage-completed-file-edit-remains-paired-with-its-result-after-resume.yaml @@ -0,0 +1,116 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Read paired.txt with Read, then use Edit exactly once to replace PAIR_BEFORE with PAIR_AFTER. Do not search for files or run a shell command. Then reply exactly "EDIT_DONE". + response: + content: + - type: tool_use + id: toolcall_0 + name: Read + input: + file_path: ${workdir}/paired.txt + stopReason: tool_use + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Read paired.txt with Read, then use Edit exactly once to replace PAIR_BEFORE with PAIR_AFTER. Do not search for files or run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: + - type: tool_use + name: Read + input: + file_path: ${workdir}/paired.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: "1\tPAIR_BEFORE\n2" + response: + content: + - type: tool_use + id: toolcall_1 + name: Edit + input: + file_path: ${workdir}/paired.txt + old_string: PAIR_BEFORE + new_string: PAIR_AFTER + stopReason: tool_use + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Read paired.txt with Read, then use Edit exactly once to replace PAIR_BEFORE with PAIR_AFTER. Do not search for files or run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: + - type: tool_use + name: Read + input: + file_path: ${workdir}/paired.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: "1\tPAIR_BEFORE\n2" + - role: assistant + content: + - type: tool_use + name: Edit + input: + replace_all: false + file_path: ${workdir}/paired.txt + old_string: PAIR_BEFORE + new_string: PAIR_AFTER + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: The file ${workdir}/paired.txt has been updated successfully. (file state is current in your context — no need to Read it back) + response: + content: EDIT_DONE + stopReason: end_turn + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Read paired.txt with Read, then use Edit exactly once to replace PAIR_BEFORE with PAIR_AFTER. Do not search for files or run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: + - type: tool_use + name: Read + input: + file_path: ${workdir}/paired.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: "1\tPAIR_BEFORE\n2" + - role: assistant + content: + - type: tool_use + name: Edit + input: + replace_all: false + file_path: ${workdir}/paired.txt + old_string: PAIR_BEFORE + new_string: PAIR_AFTER + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: The file ${workdir}/paired.txt has been updated successfully. (file state is current in your context — no need to Read it back) + - role: assistant + content: EDIT_DONE + - role: user + content: Reply exactly "HISTORY_READY". Do not use tools. + response: + content: HISTORY_READY + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-regression-coverage-identical-prompts-remain-separate-completed-turns-after-resume.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-regression-coverage-identical-prompts-remain-separate-completed-turns-after-resume.yaml new file mode 100644 index 00000000000000..5c99164cb89b9a --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-regression-coverage-identical-prompts-remain-separate-completed-turns-after-resume.yaml @@ -0,0 +1,42 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Reply exactly "REPEATED_READY". Do not use tools. + response: + content: REPEATED_READY + stopReason: end_turn + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Reply exactly "REPEATED_READY". Do not use tools. + - role: assistant + content: REPEATED_READY + - role: user + content: Reply exactly "REPEATED_READY". Do not use tools. + response: + content: REPEATED_READY + stopReason: end_turn + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Reply exactly "REPEATED_READY". Do not use tools. + - role: assistant + content: REPEATED_READY + - role: user + content: Reply exactly "REPEATED_READY". Do not use tools. + - role: assistant + content: REPEATED_READY + - role: user + content: Reply exactly "CONTINUED". Do not use tools. + response: + content: CONTINUED + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-regression-coverage-peer-tool-history-and-subsequent-edits-stay-on-the-restored-peer.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-regression-coverage-peer-tool-history-and-subsequent-edits-stay-on-the-restored-peer.yaml new file mode 100644 index 00000000000000..92fd184be10464 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-regression-coverage-peer-tool-history-and-subsequent-edits-stay-on-the-restored-peer.yaml @@ -0,0 +1,249 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Remember DEFAULT_HISTORY_ONLY. Reply exactly "DEFAULT_READY". + response: + content: DEFAULT_READY + stopReason: end_turn + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Read peer-edit.txt with Read, then use Edit exactly once to replace PEER_BEFORE with PEER_MIDDLE. Do not search for files or run a shell command. Then reply exactly "EDIT_DONE". + response: + content: + - type: tool_use + id: toolcall_0 + name: Read + input: + file_path: ${workdir}/peer-edit.txt + stopReason: tool_use + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Read peer-edit.txt with Read, then use Edit exactly once to replace PEER_BEFORE with PEER_MIDDLE. Do not search for files or run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: + - type: tool_use + name: Read + input: + file_path: ${workdir}/peer-edit.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: "1\tPEER_BEFORE\n2" + response: + content: + - type: tool_use + id: toolcall_1 + name: Edit + input: + file_path: ${workdir}/peer-edit.txt + old_string: PEER_BEFORE + new_string: PEER_MIDDLE + stopReason: tool_use + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Read peer-edit.txt with Read, then use Edit exactly once to replace PEER_BEFORE with PEER_MIDDLE. Do not search for files or run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: + - type: tool_use + name: Read + input: + file_path: ${workdir}/peer-edit.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: "1\tPEER_BEFORE\n2" + - role: assistant + content: + - type: tool_use + name: Edit + input: + replace_all: false + file_path: ${workdir}/peer-edit.txt + old_string: PEER_BEFORE + new_string: PEER_MIDDLE + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: The file ${workdir}/peer-edit.txt has been updated successfully. (file state is current in your context — no need to Read it back) + response: + content: EDIT_DONE + stopReason: end_turn + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Read peer-edit.txt with Read, then use Edit exactly once to replace PEER_BEFORE with PEER_MIDDLE. Do not search for files or run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: + - type: tool_use + name: Read + input: + file_path: ${workdir}/peer-edit.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: "1\tPEER_BEFORE\n2" + - role: assistant + content: + - type: tool_use + name: Edit + input: + replace_all: false + file_path: ${workdir}/peer-edit.txt + old_string: PEER_BEFORE + new_string: PEER_MIDDLE + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: The file ${workdir}/peer-edit.txt has been updated successfully. (file state is current in your context — no need to Read it back) + - role: assistant + content: EDIT_DONE + - role: user + content: Read peer-edit.txt with Read, then use Edit exactly once to replace PEER_MIDDLE with PEER_AFTER. Do not search for files or run a shell command. Then reply exactly "EDIT_DONE". + response: + content: + - type: tool_use + id: toolcall_2 + name: Read + input: + file_path: ${workdir}/peer-edit.txt + stopReason: tool_use + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Read peer-edit.txt with Read, then use Edit exactly once to replace PEER_BEFORE with PEER_MIDDLE. Do not search for files or run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: + - type: tool_use + name: Read + input: + file_path: ${workdir}/peer-edit.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: "1\tPEER_BEFORE\n2" + - role: assistant + content: + - type: tool_use + name: Edit + input: + replace_all: false + file_path: ${workdir}/peer-edit.txt + old_string: PEER_BEFORE + new_string: PEER_MIDDLE + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: The file ${workdir}/peer-edit.txt has been updated successfully. (file state is current in your context — no need to Read it back) + - role: assistant + content: EDIT_DONE + - role: user + content: Read peer-edit.txt with Read, then use Edit exactly once to replace PEER_MIDDLE with PEER_AFTER. Do not search for files or run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: + - type: tool_use + name: Read + input: + file_path: ${workdir}/peer-edit.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_2 + content: "1\tPEER_MIDDLE\n2" + response: + content: + - type: tool_use + id: toolcall_3 + name: Edit + input: + file_path: ${workdir}/peer-edit.txt + old_string: PEER_MIDDLE + new_string: PEER_AFTER + stopReason: tool_use + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Read peer-edit.txt with Read, then use Edit exactly once to replace PEER_BEFORE with PEER_MIDDLE. Do not search for files or run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: + - type: tool_use + name: Read + input: + file_path: ${workdir}/peer-edit.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: "1\tPEER_BEFORE\n2" + - role: assistant + content: + - type: tool_use + name: Edit + input: + replace_all: false + file_path: ${workdir}/peer-edit.txt + old_string: PEER_BEFORE + new_string: PEER_MIDDLE + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: The file ${workdir}/peer-edit.txt has been updated successfully. (file state is current in your context — no need to Read it back) + - role: assistant + content: EDIT_DONE + - role: user + content: Read peer-edit.txt with Read, then use Edit exactly once to replace PEER_MIDDLE with PEER_AFTER. Do not search for files or run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: + - type: tool_use + name: Read + input: + file_path: ${workdir}/peer-edit.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_2 + content: "1\tPEER_MIDDLE\n2" + - role: assistant + content: + - type: tool_use + name: Edit + input: + replace_all: false + file_path: ${workdir}/peer-edit.txt + old_string: PEER_MIDDLE + new_string: PEER_AFTER + - role: user + content: + - type: tool_result + tool_use_id: toolcall_3 + content: The file ${workdir}/peer-edit.txt has been updated successfully. (file state is current in your context — no need to Read it back) + response: + content: EDIT_DONE + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-regression-coverage-successive-file-edits-retain-their-turn-ownership-after-restart.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-regression-coverage-successive-file-edits-retain-their-turn-ownership-after-restart.yaml new file mode 100644 index 00000000000000..b14e34ac44daf6 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-regression-coverage-successive-file-edits-retain-their-turn-ownership-after-restart.yaml @@ -0,0 +1,307 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Read successive.txt with Read, then use Edit exactly once to replace EDIT_ZERO with EDIT_ONE. Do not search for files or run a shell command. Then reply exactly "EDIT_DONE". + response: + content: + - type: tool_use + id: toolcall_0 + name: Read + input: + file_path: ${workdir}/successive.txt + stopReason: tool_use + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Read successive.txt with Read, then use Edit exactly once to replace EDIT_ZERO with EDIT_ONE. Do not search for files or run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: + - type: tool_use + name: Read + input: + file_path: ${workdir}/successive.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: "1\tEDIT_ZERO\n2" + response: + content: + - type: tool_use + id: toolcall_1 + name: Edit + input: + file_path: ${workdir}/successive.txt + old_string: EDIT_ZERO + new_string: EDIT_ONE + stopReason: tool_use + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Read successive.txt with Read, then use Edit exactly once to replace EDIT_ZERO with EDIT_ONE. Do not search for files or run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: + - type: tool_use + name: Read + input: + file_path: ${workdir}/successive.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: "1\tEDIT_ZERO\n2" + - role: assistant + content: + - type: tool_use + name: Edit + input: + replace_all: false + file_path: ${workdir}/successive.txt + old_string: EDIT_ZERO + new_string: EDIT_ONE + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: The file ${workdir}/successive.txt has been updated successfully. (file state is current in your context — no need to Read it back) + response: + content: EDIT_DONE + stopReason: end_turn + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Read successive.txt with Read, then use Edit exactly once to replace EDIT_ZERO with EDIT_ONE. Do not search for files or run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: + - type: tool_use + name: Read + input: + file_path: ${workdir}/successive.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: "1\tEDIT_ZERO\n2" + - role: assistant + content: + - type: tool_use + name: Edit + input: + replace_all: false + file_path: ${workdir}/successive.txt + old_string: EDIT_ZERO + new_string: EDIT_ONE + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: The file ${workdir}/successive.txt has been updated successfully. (file state is current in your context — no need to Read it back) + - role: assistant + content: EDIT_DONE + - role: user + content: Read successive.txt with Read, then use Edit exactly once to replace EDIT_ONE with EDIT_TWO. Do not search for files or run a shell command. Then reply exactly "EDIT_DONE". + response: + content: + - type: tool_use + id: toolcall_2 + name: Read + input: + file_path: ${workdir}/successive.txt + stopReason: tool_use + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Read successive.txt with Read, then use Edit exactly once to replace EDIT_ZERO with EDIT_ONE. Do not search for files or run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: + - type: tool_use + name: Read + input: + file_path: ${workdir}/successive.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: "1\tEDIT_ZERO\n2" + - role: assistant + content: + - type: tool_use + name: Edit + input: + replace_all: false + file_path: ${workdir}/successive.txt + old_string: EDIT_ZERO + new_string: EDIT_ONE + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: The file ${workdir}/successive.txt has been updated successfully. (file state is current in your context — no need to Read it back) + - role: assistant + content: EDIT_DONE + - role: user + content: Read successive.txt with Read, then use Edit exactly once to replace EDIT_ONE with EDIT_TWO. Do not search for files or run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: + - type: tool_use + name: Read + input: + file_path: ${workdir}/successive.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_2 + content: "1\tEDIT_ONE\n2" + response: + content: + - type: tool_use + id: toolcall_3 + name: Edit + input: + file_path: ${workdir}/successive.txt + old_string: EDIT_ONE + new_string: EDIT_TWO + stopReason: tool_use + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Read successive.txt with Read, then use Edit exactly once to replace EDIT_ZERO with EDIT_ONE. Do not search for files or run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: + - type: tool_use + name: Read + input: + file_path: ${workdir}/successive.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: "1\tEDIT_ZERO\n2" + - role: assistant + content: + - type: tool_use + name: Edit + input: + replace_all: false + file_path: ${workdir}/successive.txt + old_string: EDIT_ZERO + new_string: EDIT_ONE + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: The file ${workdir}/successive.txt has been updated successfully. (file state is current in your context — no need to Read it back) + - role: assistant + content: EDIT_DONE + - role: user + content: Read successive.txt with Read, then use Edit exactly once to replace EDIT_ONE with EDIT_TWO. Do not search for files or run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: + - type: tool_use + name: Read + input: + file_path: ${workdir}/successive.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_2 + content: "1\tEDIT_ONE\n2" + - role: assistant + content: + - type: tool_use + name: Edit + input: + replace_all: false + file_path: ${workdir}/successive.txt + old_string: EDIT_ONE + new_string: EDIT_TWO + - role: user + content: + - type: tool_result + tool_use_id: toolcall_3 + content: The file ${workdir}/successive.txt has been updated successfully. (file state is current in your context — no need to Read it back) + response: + content: EDIT_DONE + stopReason: end_turn + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Read successive.txt with Read, then use Edit exactly once to replace EDIT_ZERO with EDIT_ONE. Do not search for files or run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: + - type: tool_use + name: Read + input: + file_path: ${workdir}/successive.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: "1\tEDIT_ZERO\n2" + - role: assistant + content: + - type: tool_use + name: Edit + input: + replace_all: false + file_path: ${workdir}/successive.txt + old_string: EDIT_ZERO + new_string: EDIT_ONE + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: The file ${workdir}/successive.txt has been updated successfully. (file state is current in your context — no need to Read it back) + - role: assistant + content: EDIT_DONE + - role: user + content: Read successive.txt with Read, then use Edit exactly once to replace EDIT_ONE with EDIT_TWO. Do not search for files or run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: + - type: tool_use + name: Read + input: + file_path: ${workdir}/successive.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_2 + content: "1\tEDIT_ONE\n2" + - role: assistant + content: + - type: tool_use + name: Edit + input: + replace_all: false + file_path: ${workdir}/successive.txt + old_string: EDIT_ONE + new_string: EDIT_TWO + - role: user + content: + - type: tool_result + tool_use_id: toolcall_3 + content: The file ${workdir}/successive.txt has been updated successfully. (file state is current in your context — no need to Read it back) + - role: assistant + content: EDIT_DONE + - role: user + content: Reply exactly "TWO_EDITS_READY". Do not use tools. + response: + content: TWO_EDITS_READY + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-mcp-side-channel-regression-coverage-isolates-a-concurrent-server-error-from-successful-tool-and-resource-responses.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-mcp-side-channel-regression-coverage-isolates-a-concurrent-server-error-from-successful-tool-and-resource-responses.yaml new file mode 100644 index 00000000000000..e19e5e3f6e7562 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-mcp-side-channel-regression-coverage-isolates-a-concurrent-server-error-from-successful-tool-and-resource-responses.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + response: + content: READY + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-mcp-side-channel-regression-coverage-preserves-multiple-resource-contents-with-distinct-uris-and-encodings.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-mcp-side-channel-regression-coverage-preserves-multiple-resource-contents-with-distinct-uris-and-encodings.yaml new file mode 100644 index 00000000000000..e19e5e3f6e7562 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-mcp-side-channel-regression-coverage-preserves-multiple-resource-contents-with-distinct-uris-and-encodings.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + response: + content: READY + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-mcp-side-channel-regression-coverage-preserves-ordered-text-image-and-embedded-resource-tool-content.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-mcp-side-channel-regression-coverage-preserves-ordered-text-image-and-embedded-resource-tool-content.yaml new file mode 100644 index 00000000000000..e19e5e3f6e7562 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-mcp-side-channel-regression-coverage-preserves-ordered-text-image-and-embedded-resource-tool-content.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + response: + content: READY + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-mcp-side-channel-regression-coverage-preserves-structured-tool-results-and-private-result-metadata.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-mcp-side-channel-regression-coverage-preserves-structured-tool-results-and-private-result-metadata.yaml new file mode 100644 index 00000000000000..e19e5e3f6e7562 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-mcp-side-channel-regression-coverage-preserves-structured-tool-results-and-private-result-metadata.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + response: + content: READY + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-mcp-side-channel-regression-coverage-retries-the-same-resource-after-a-transient-real-server-error.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-mcp-side-channel-regression-coverage-retries-the-same-resource-after-a-transient-real-server-error.yaml new file mode 100644 index 00000000000000..e19e5e3f6e7562 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-mcp-side-channel-regression-coverage-retries-the-same-resource-after-a-transient-real-server-error.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + response: + content: READY + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-mcp-side-channel-regression-coverage-returns-an-empty-successful-tool-result-without-fabricating-text.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-mcp-side-channel-regression-coverage-returns-an-empty-successful-tool-result-without-fabricating-text.yaml new file mode 100644 index 00000000000000..e19e5e3f6e7562 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-mcp-side-channel-regression-coverage-returns-an-empty-successful-tool-result-without-fabricating-text.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + response: + content: READY + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-mcp-side-channel-regression-coverage-returns-resource-links-that-can-be-read-on-the-same-advertised-channel.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-mcp-side-channel-regression-coverage-returns-resource-links-that-can-be-read-on-the-same-advertised-channel.yaml new file mode 100644 index 00000000000000..e19e5e3f6e7562 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-mcp-side-channel-regression-coverage-returns-resource-links-that-can-be-read-on-the-same-advertised-channel.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + response: + content: READY + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-a-first-turn-fork-excludes-later-source-context.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-a-first-turn-fork-excludes-later-source-context.yaml new file mode 100644 index 00000000000000..17e45de8e023db --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-a-first-turn-fork-excludes-later-source-context.yaml @@ -0,0 +1,38 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Remember FIRST_FORK_INCLUDED. Reply exactly "FIRST_READY". + response: + content: FIRST_READY + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Remember FIRST_FORK_INCLUDED. Reply exactly "FIRST_READY". + - role: assistant + content: FIRST_READY + - role: user + content: Remember LATER_FORK_EXCLUDED. Reply exactly "LATER_READY". + response: + content: LATER_READY + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Remember FIRST_FORK_INCLUDED. Reply exactly "FIRST_READY". + - role: assistant + content: FIRST_READY + - role: user + content: Reply with the code word from the source conversation. Do not use tools. + response: + content: FIRST_FORK_INCLUDED + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-a-fork-resumes-its-own-branch-after-the-source-advances.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-a-fork-resumes-its-own-branch-after-the-source-advances.yaml new file mode 100644 index 00000000000000..68c32d3830021b --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-a-fork-resumes-its-own-branch-after-the-source-advances.yaml @@ -0,0 +1,72 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Remember SHARED_FORK_PREFIX. Reply exactly "PREFIX_READY". + response: + content: PREFIX_READY + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Remember SHARED_FORK_PREFIX. Reply exactly "PREFIX_READY". + - role: assistant + content: PREFIX_READY + - role: user + content: Remember FORK_BRANCH_ONLY. Reply exactly "BRANCH_READY". + response: + content: BRANCH_READY + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Remember SHARED_FORK_PREFIX. Reply exactly "PREFIX_READY". + - role: assistant + content: PREFIX_READY + - role: user + content: Remember SOURCE_ADVANCE_ONLY. Reply exactly "SOURCE_ADVANCED". + response: + content: SOURCE_ADVANCED + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Remember SHARED_FORK_PREFIX. Reply exactly "PREFIX_READY". + - role: assistant + content: PREFIX_READY + - role: user + content: Remember FORK_BRANCH_ONLY. Reply exactly "BRANCH_READY". + - role: assistant + content: BRANCH_READY + - role: user + content: Reply exactly "BRANCH_RESUMED". Do not use tools. + response: + content: BRANCH_RESUMED + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Remember SHARED_FORK_PREFIX. Reply exactly "PREFIX_READY". + - role: assistant + content: PREFIX_READY + - role: user + content: Remember SOURCE_ADVANCE_ONLY. Reply exactly "SOURCE_ADVANCED". + - role: assistant + content: SOURCE_ADVANCED + - role: user + content: Reply exactly "SOURCE_RESUMED". Do not use tools. + response: + content: SOURCE_RESUMED + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-a-middle-turn-fork-includes-completed-tools-but-excludes-the-next-turn.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-a-middle-turn-fork-includes-completed-tools-but-excludes-the-next-turn.yaml new file mode 100644 index 00000000000000..fe5bcdd2e28cb8 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-a-middle-turn-fork-includes-completed-tools-but-excludes-the-next-turn.yaml @@ -0,0 +1,171 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Remember MIDDLE_FORK_SEED. Reply exactly "SEED_READY". + response: + content: SEED_READY + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Remember MIDDLE_FORK_SEED. Reply exactly "SEED_READY". + - role: assistant + content: SEED_READY + - role: user + content: Use your file editing tool exactly once to replace MIDDLE_BEFORE with MIDDLE_AFTER in middle.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + response: + content: + - type: text + text: Applying the one requested edit to `middle.txt` now. + - type: tool_use + id: toolcall_0 + name: exec_command + input: + cmd: |- + apply_patch <<'PATCH' + *** Begin Patch + *** Update File: middle.txt + @@ + -MIDDLE_BEFORE + +MIDDLE_AFTER + *** End Patch + PATCH + stopReason: tool_use + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Remember MIDDLE_FORK_SEED. Reply exactly "SEED_READY". + - role: assistant + content: SEED_READY + - role: user + content: Use your file editing tool exactly once to replace MIDDLE_BEFORE with MIDDLE_AFTER in middle.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: Applying the one requested edit to `middle.txt` now. + - role: assistant + content: + - type: tool_use + name: exec_command + input: + cmd: |- + apply_patch <<'PATCH' + *** Begin Patch + *** Update File: middle.txt + @@ + -MIDDLE_BEFORE + +MIDDLE_AFTER + *** End Patch + PATCH + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: |- + Wall time: 0.0000 seconds + Output: + Exit code: 0 + Wall time: 0 seconds + Output: + Success. Updated the following files: + M middle.txt + response: + content: EDIT_DONE + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Remember MIDDLE_FORK_SEED. Reply exactly "SEED_READY". + - role: assistant + content: SEED_READY + - role: user + content: Use your file editing tool exactly once to replace MIDDLE_BEFORE with MIDDLE_AFTER in middle.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: Applying the one requested edit to `middle.txt` now. + - role: assistant + content: + - type: tool_use + name: exec_command + input: + cmd: |- + apply_patch <<'PATCH' + *** Begin Patch + *** Update File: middle.txt + @@ + -MIDDLE_BEFORE + +MIDDLE_AFTER + *** End Patch + PATCH + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: |- + Wall time: 0.0000 seconds + Output: + Exit code: 0 + Wall time: 0 seconds + Output: + Success. Updated the following files: + M middle.txt + - role: assistant + content: EDIT_DONE + - role: user + content: Remember AFTER_BOUNDARY_EXCLUDED. Reply exactly "EXCLUDED_READY". + response: + content: EXCLUDED_READY + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Remember MIDDLE_FORK_SEED. Reply exactly "SEED_READY". + - role: assistant + content: SEED_READY + - role: user + content: Use your file editing tool exactly once to replace MIDDLE_BEFORE with MIDDLE_AFTER in middle.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: Applying the one requested edit to `middle.txt` now. + - role: assistant + content: + - type: tool_use + name: exec_command + input: + cmd: |- + apply_patch <<'PATCH' + *** Begin Patch + *** Update File: middle.txt + @@ + -MIDDLE_BEFORE + +MIDDLE_AFTER + *** End Patch + PATCH + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: |- + Wall time: 0.0000 seconds + Output: + Exit code: 0 + Wall time: 0 seconds + Output: + Success. Updated the following files: + M middle.txt + - role: assistant + content: EDIT_DONE + - role: user + content: Reply exactly "FORK_HISTORY_READY". Do not use tools. + response: + content: FORK_HISTORY_READY + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-a-restored-peer-can-be-forked-without-changing-its-source-history.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-a-restored-peer-can-be-forked-without-changing-its-source-history.yaml new file mode 100644 index 00000000000000..f914148ba9bf8e --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-a-restored-peer-can-be-forked-without-changing-its-source-history.yaml @@ -0,0 +1,148 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Remember DEFAULT_FORK_EXCLUDED. Reply exactly "DEFAULT_READY". + response: + content: DEFAULT_READY + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Remember PEER_FORK_INCLUDED. Reply exactly "PEER_READY". + response: + content: PEER_READY + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Remember PEER_FORK_INCLUDED. Reply exactly "PEER_READY". + - role: assistant + content: PEER_READY + - role: user + content: Remember PEER_LATER_EXCLUDED. Reply exactly "PEER_LATER_READY". + response: + content: PEER_LATER_READY + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Remember PEER_FORK_INCLUDED. Reply exactly "PEER_READY". + - role: assistant + content: PEER_READY + - role: user + content: Reply exactly "FORK_CONTINUED". Do not use tools. + response: + content: FORK_CONTINUED + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Remember PEER_FORK_INCLUDED. Reply exactly "PEER_READY". + - role: assistant + content: PEER_READY + - role: user + content: Remember PEER_LATER_EXCLUDED. Reply exactly "PEER_LATER_READY". + - role: assistant + content: PEER_LATER_READY + - role: user + content: |- + Call exec_command exactly once with this exact command, without modifying or wrapping it: + ```sh + apply_patch <<'PATCH' + *** Begin Patch + *** Update File: source-continues.txt + @@ + -SOURCE_BEFORE + +SOURCE_AFTER + *** End Patch + PATCH + ``` + Do not inspect or search for files, and do not run any other command. After the patch succeeds, reply exactly "EDIT_DONE". + response: + content: + - type: text + text: Applying the exact patch command now. + - type: tool_use + id: toolcall_0 + name: exec_command + input: + cmd: | + apply_patch <<'PATCH' + *** Begin Patch + *** Update File: source-continues.txt + @@ + -SOURCE_BEFORE + +SOURCE_AFTER + *** End Patch + PATCH + stopReason: tool_use + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Remember PEER_FORK_INCLUDED. Reply exactly "PEER_READY". + - role: assistant + content: PEER_READY + - role: user + content: Remember PEER_LATER_EXCLUDED. Reply exactly "PEER_LATER_READY". + - role: assistant + content: PEER_LATER_READY + - role: user + content: |- + Call exec_command exactly once with this exact command, without modifying or wrapping it: + ```sh + apply_patch <<'PATCH' + *** Begin Patch + *** Update File: source-continues.txt + @@ + -SOURCE_BEFORE + +SOURCE_AFTER + *** End Patch + PATCH + ``` + Do not inspect or search for files, and do not run any other command. After the patch succeeds, reply exactly "EDIT_DONE". + - role: assistant + content: Applying the exact patch command now. + - role: assistant + content: + - type: tool_use + name: exec_command + input: + cmd: | + apply_patch <<'PATCH' + *** Begin Patch + *** Update File: source-continues.txt + @@ + -SOURCE_BEFORE + +SOURCE_AFTER + *** End Patch + PATCH + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: |- + Wall time: 0.0000 seconds + Output: + Exit code: 0 + Wall time: 0 seconds + Output: + Success. Updated the following files: + M source-continues.txt + response: + content: EDIT_DONE + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-an-unstarted-peer-can-execute-its-first-tool-turn-after-restart.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-an-unstarted-peer-can-execute-its-first-tool-turn-after-restart.yaml new file mode 100644 index 00000000000000..ac8e1f5eec5899 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-an-unstarted-peer-can-execute-its-first-tool-turn-after-restart.yaml @@ -0,0 +1,82 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Remember DEFAULT_NOT_IN_PEER. Reply exactly "READY". + response: + content: READY + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Remember SIBLING_NOT_IN_PEER. Reply exactly "READY". + response: + content: READY + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Use your file editing tool exactly once to replace FIRST_BEFORE with FIRST_AFTER in first-peer.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + response: + content: + - type: text + text: Applying one direct patch to `first-peer.txt` now. + - type: tool_use + id: toolcall_0 + name: exec_command + input: + cmd: |- + apply_patch <<'PATCH' + *** Begin Patch + *** Update File: first-peer.txt + @@ + -FIRST_BEFORE + +FIRST_AFTER + *** End Patch + PATCH + stopReason: tool_use + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Use your file editing tool exactly once to replace FIRST_BEFORE with FIRST_AFTER in first-peer.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: Applying one direct patch to `first-peer.txt` now. + - role: assistant + content: + - type: tool_use + name: exec_command + input: + cmd: |- + apply_patch <<'PATCH' + *** Begin Patch + *** Update File: first-peer.txt + @@ + -FIRST_BEFORE + +FIRST_AFTER + *** End Patch + PATCH + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: |- + Wall time: 0.0000 seconds + Output: + Exit code: 0 + Wall time: 0 seconds + Output: + Success. Updated the following files: + M first-peer.txt + response: + content: EDIT_DONE + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-catalog-refresh-does-not-promote-restored-peer-backings-to-sessions.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-catalog-refresh-does-not-promote-restored-peer-backings-to-sessions.yaml new file mode 100644 index 00000000000000..ed84fc9d481f6b --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-catalog-refresh-does-not-promote-restored-peer-backings-to-sessions.yaml @@ -0,0 +1,34 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Reply exactly "CATALOG_READY". + response: + content: CATALOG_READY + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Remember CATALOG_PEER_ONLY. Reply exactly "PEER_READY". + response: + content: PEER_READY + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Remember CATALOG_PEER_ONLY. Reply exactly "PEER_READY". + - role: assistant + content: PEER_READY + - role: user + content: Reply exactly "PEER_CONTINUED". Do not use tools. + response: + content: PEER_CONTINUED + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-cold-restored-peers-retain-independent-provider-contexts.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-cold-restored-peers-retain-independent-provider-contexts.yaml new file mode 100644 index 00000000000000..7aee47d863cfcf --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-cold-restored-peers-retain-independent-provider-contexts.yaml @@ -0,0 +1,56 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Reply exactly "DEFAULT_READY". + response: + content: DEFAULT_READY + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Remember PEER_RED_ONLY. Reply exactly "RED_READY". + response: + content: RED_READY + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Remember PEER_BLUE_ONLY. Reply exactly "BLUE_READY". + response: + content: BLUE_READY + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Remember PEER_BLUE_ONLY. Reply exactly "BLUE_READY". + - role: assistant + content: BLUE_READY + - role: user + content: Reply with the code word I gave this chat. Do not use tools. + response: + content: PEER_BLUE_ONLY + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Remember PEER_RED_ONLY. Reply exactly "RED_READY". + - role: assistant + content: RED_READY + - role: user + content: Reply with the code word I gave this chat. Do not use tools. + response: + content: PEER_RED_ONLY + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-cold-resume-retains-ordered-user-and-assistant-context.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-cold-resume-retains-ordered-user-and-assistant-context.yaml new file mode 100644 index 00000000000000..087d67ae16a10d --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-cold-resume-retains-ordered-user-and-assistant-context.yaml @@ -0,0 +1,44 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Remember the first entry ORDERED_ALPHA. Reply exactly "ALPHA_READY". + response: + content: ALPHA_READY + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Remember the first entry ORDERED_ALPHA. Reply exactly "ALPHA_READY". + - role: assistant + content: ALPHA_READY + - role: user + content: Remember the second entry ORDERED_BETA without replacing the first. Reply exactly "BETA_READY". + response: + content: BETA_READY + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Remember the first entry ORDERED_ALPHA. Reply exactly "ALPHA_READY". + - role: assistant + content: ALPHA_READY + - role: user + content: Remember the second entry ORDERED_BETA without replacing the first. Reply exactly "BETA_READY". + - role: assistant + content: BETA_READY + - role: user + content: Reply with both entries in the order I gave them. Do not use tools. + response: + content: |- + ORDERED_ALPHA + ORDERED_BETA + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-completed-file-edit-remains-paired-with-its-result-after-resume.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-completed-file-edit-remains-paired-with-its-result-after-resume.yaml new file mode 100644 index 00000000000000..c052fb05033929 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-completed-file-edit-remains-paired-with-its-result-after-resume.yaml @@ -0,0 +1,105 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Use your file editing tool exactly once to replace PAIR_BEFORE with PAIR_AFTER in paired.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + response: + content: + - type: text + text: Applying the requested single edit now. + - type: tool_use + id: toolcall_0 + name: exec_command + input: + cmd: |- + apply_patch <<'PATCH' + *** Begin Patch + *** Update File: paired.txt + @@ + -PAIR_BEFORE + +PAIR_AFTER + *** End Patch + PATCH + stopReason: tool_use + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Use your file editing tool exactly once to replace PAIR_BEFORE with PAIR_AFTER in paired.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: Applying the requested single edit now. + - role: assistant + content: + - type: tool_use + name: exec_command + input: + cmd: |- + apply_patch <<'PATCH' + *** Begin Patch + *** Update File: paired.txt + @@ + -PAIR_BEFORE + +PAIR_AFTER + *** End Patch + PATCH + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: |- + Wall time: 0.0000 seconds + Output: + Exit code: 0 + Wall time: 0 seconds + Output: + Success. Updated the following files: + M paired.txt + response: + content: EDIT_DONE + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Use your file editing tool exactly once to replace PAIR_BEFORE with PAIR_AFTER in paired.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: Applying the requested single edit now. + - role: assistant + content: + - type: tool_use + name: exec_command + input: + cmd: |- + apply_patch <<'PATCH' + *** Begin Patch + *** Update File: paired.txt + @@ + -PAIR_BEFORE + +PAIR_AFTER + *** End Patch + PATCH + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: |- + Wall time: 0.0000 seconds + Output: + Exit code: 0 + Wall time: 0 seconds + Output: + Success. Updated the following files: + M paired.txt + - role: assistant + content: EDIT_DONE + - role: user + content: Reply exactly "HISTORY_READY". Do not use tools. + response: + content: HISTORY_READY + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-identical-prompts-remain-separate-completed-turns-after-resume.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-identical-prompts-remain-separate-completed-turns-after-resume.yaml new file mode 100644 index 00000000000000..4e15355294e0dd --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-identical-prompts-remain-separate-completed-turns-after-resume.yaml @@ -0,0 +1,42 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Reply exactly "REPEATED_READY". Do not use tools. + response: + content: REPEATED_READY + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Reply exactly "REPEATED_READY". Do not use tools. + - role: assistant + content: REPEATED_READY + - role: user + content: Reply exactly "REPEATED_READY". Do not use tools. + response: + content: REPEATED_READY + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Reply exactly "REPEATED_READY". Do not use tools. + - role: assistant + content: REPEATED_READY + - role: user + content: Reply exactly "REPEATED_READY". Do not use tools. + - role: assistant + content: REPEATED_READY + - role: user + content: Reply exactly "CONTINUED". Do not use tools. + response: + content: CONTINUED + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-peer-tool-history-and-subsequent-edits-stay-on-the-restored-peer.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-peer-tool-history-and-subsequent-edits-stay-on-the-restored-peer.yaml new file mode 100644 index 00000000000000..e12e043acc5dc0 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-peer-tool-history-and-subsequent-edits-stay-on-the-restored-peer.yaml @@ -0,0 +1,198 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Remember DEFAULT_HISTORY_ONLY. Reply exactly "DEFAULT_READY". + response: + content: DEFAULT_READY + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Use your file editing tool exactly once to replace PEER_BEFORE with PEER_MIDDLE in peer-edit.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + response: + content: + - type: text + text: Applying the one requested text replacement in the target file now. + - type: tool_use + id: toolcall_0 + name: exec_command + input: + cmd: |- + apply_patch <<'PATCH' + *** Begin Patch + *** Update File: peer-edit.txt + @@ + -PEER_BEFORE + +PEER_MIDDLE + *** End Patch + PATCH + stopReason: tool_use + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Use your file editing tool exactly once to replace PEER_BEFORE with PEER_MIDDLE in peer-edit.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: Applying the one requested text replacement in the target file now. + - role: assistant + content: + - type: tool_use + name: exec_command + input: + cmd: |- + apply_patch <<'PATCH' + *** Begin Patch + *** Update File: peer-edit.txt + @@ + -PEER_BEFORE + +PEER_MIDDLE + *** End Patch + PATCH + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: |- + Wall time: 0.0000 seconds + Output: + Exit code: 0 + Wall time: 0 seconds + Output: + Success. Updated the following files: + M peer-edit.txt + response: + content: EDIT_DONE + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Use your file editing tool exactly once to replace PEER_BEFORE with PEER_MIDDLE in peer-edit.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: Applying the one requested text replacement in the target file now. + - role: assistant + content: + - type: tool_use + name: exec_command + input: + cmd: |- + apply_patch <<'PATCH' + *** Begin Patch + *** Update File: peer-edit.txt + @@ + -PEER_BEFORE + +PEER_MIDDLE + *** End Patch + PATCH + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: |- + Wall time: 0.0000 seconds + Output: + Exit code: 0 + Wall time: 0 seconds + Output: + Success. Updated the following files: + M peer-edit.txt + - role: assistant + content: EDIT_DONE + - role: user + content: Use your file editing tool exactly once to replace PEER_MIDDLE with PEER_AFTER in peer-edit.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + response: + content: + - type: text + text: Applying the requested single replacement in `peer-edit.txt` now. + - type: tool_use + id: toolcall_1 + name: exec_command + input: + cmd: |- + apply_patch <<'PATCH' + *** Begin Patch + *** Update File: peer-edit.txt + @@ + -PEER_MIDDLE + +PEER_AFTER + *** End Patch + PATCH + stopReason: tool_use + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Use your file editing tool exactly once to replace PEER_BEFORE with PEER_MIDDLE in peer-edit.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: Applying the one requested text replacement in the target file now. + - role: assistant + content: + - type: tool_use + name: exec_command + input: + cmd: |- + apply_patch <<'PATCH' + *** Begin Patch + *** Update File: peer-edit.txt + @@ + -PEER_BEFORE + +PEER_MIDDLE + *** End Patch + PATCH + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: |- + Wall time: 0.0000 seconds + Output: + Exit code: 0 + Wall time: 0 seconds + Output: + Success. Updated the following files: + M peer-edit.txt + - role: assistant + content: EDIT_DONE + - role: user + content: Use your file editing tool exactly once to replace PEER_MIDDLE with PEER_AFTER in peer-edit.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: Applying the requested single replacement in `peer-edit.txt` now. + - role: assistant + content: + - type: tool_use + name: exec_command + input: + cmd: |- + apply_patch <<'PATCH' + *** Begin Patch + *** Update File: peer-edit.txt + @@ + -PEER_MIDDLE + +PEER_AFTER + *** End Patch + PATCH + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: |- + Wall time: 0.0000 seconds + Output: + Exit code: 0 + Wall time: 0 seconds + Output: + Success. Updated the following files: + M peer-edit.txt + response: + content: EDIT_DONE + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-successive-file-edits-retain-their-turn-ownership-after-restart.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-successive-file-edits-retain-their-turn-ownership-after-restart.yaml new file mode 100644 index 00000000000000..d1bb9186a50bd1 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-regression-coverage-successive-file-edits-retain-their-turn-ownership-after-restart.yaml @@ -0,0 +1,262 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Use your file editing tool exactly once to replace EDIT_ZERO with EDIT_ONE in successive.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + response: + content: + - type: text + text: Applying the single requested edit now. + - type: tool_use + id: toolcall_0 + name: exec_command + input: + cmd: |- + apply_patch <<'PATCH' + *** Begin Patch + *** Update File: successive.txt + @@ + -EDIT_ZERO + +EDIT_ONE + *** End Patch + PATCH + stopReason: tool_use + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Use your file editing tool exactly once to replace EDIT_ZERO with EDIT_ONE in successive.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: Applying the single requested edit now. + - role: assistant + content: + - type: tool_use + name: exec_command + input: + cmd: |- + apply_patch <<'PATCH' + *** Begin Patch + *** Update File: successive.txt + @@ + -EDIT_ZERO + +EDIT_ONE + *** End Patch + PATCH + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: |- + Wall time: 0.0000 seconds + Output: + Exit code: 0 + Wall time: 0 seconds + Output: + Success. Updated the following files: + M successive.txt + response: + content: EDIT_DONE + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Use your file editing tool exactly once to replace EDIT_ZERO with EDIT_ONE in successive.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: Applying the single requested edit now. + - role: assistant + content: + - type: tool_use + name: exec_command + input: + cmd: |- + apply_patch <<'PATCH' + *** Begin Patch + *** Update File: successive.txt + @@ + -EDIT_ZERO + +EDIT_ONE + *** End Patch + PATCH + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: |- + Wall time: 0.0000 seconds + Output: + Exit code: 0 + Wall time: 0 seconds + Output: + Success. Updated the following files: + M successive.txt + - role: assistant + content: EDIT_DONE + - role: user + content: Use your file editing tool exactly once to replace EDIT_ONE with EDIT_TWO in successive.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + response: + content: + - type: text + text: Applying the single requested edit now. + - type: tool_use + id: toolcall_1 + name: exec_command + input: + cmd: |- + apply_patch <<'PATCH' + *** Begin Patch + *** Update File: successive.txt + @@ + -EDIT_ONE + +EDIT_TWO + *** End Patch + PATCH + stopReason: tool_use + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Use your file editing tool exactly once to replace EDIT_ZERO with EDIT_ONE in successive.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: Applying the single requested edit now. + - role: assistant + content: + - type: tool_use + name: exec_command + input: + cmd: |- + apply_patch <<'PATCH' + *** Begin Patch + *** Update File: successive.txt + @@ + -EDIT_ZERO + +EDIT_ONE + *** End Patch + PATCH + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: |- + Wall time: 0.0000 seconds + Output: + Exit code: 0 + Wall time: 0 seconds + Output: + Success. Updated the following files: + M successive.txt + - role: assistant + content: EDIT_DONE + - role: user + content: Use your file editing tool exactly once to replace EDIT_ONE with EDIT_TWO in successive.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: Applying the single requested edit now. + - role: assistant + content: + - type: tool_use + name: exec_command + input: + cmd: |- + apply_patch <<'PATCH' + *** Begin Patch + *** Update File: successive.txt + @@ + -EDIT_ONE + +EDIT_TWO + *** End Patch + PATCH + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: |- + Wall time: 0.0000 seconds + Output: + Exit code: 0 + Wall time: 0 seconds + Output: + Success. Updated the following files: + M successive.txt + response: + content: EDIT_DONE + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Use your file editing tool exactly once to replace EDIT_ZERO with EDIT_ONE in successive.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: Applying the single requested edit now. + - role: assistant + content: + - type: tool_use + name: exec_command + input: + cmd: |- + apply_patch <<'PATCH' + *** Begin Patch + *** Update File: successive.txt + @@ + -EDIT_ZERO + +EDIT_ONE + *** End Patch + PATCH + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: |- + Wall time: 0.0000 seconds + Output: + Exit code: 0 + Wall time: 0 seconds + Output: + Success. Updated the following files: + M successive.txt + - role: assistant + content: EDIT_DONE + - role: user + content: Use your file editing tool exactly once to replace EDIT_ONE with EDIT_TWO in successive.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: Applying the single requested edit now. + - role: assistant + content: + - type: tool_use + name: exec_command + input: + cmd: |- + apply_patch <<'PATCH' + *** Begin Patch + *** Update File: successive.txt + @@ + -EDIT_ONE + +EDIT_TWO + *** End Patch + PATCH + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: |- + Wall time: 0.0000 seconds + Output: + Exit code: 0 + Wall time: 0 seconds + Output: + Success. Updated the following files: + M successive.txt + - role: assistant + content: EDIT_DONE + - role: user + content: Reply exactly "TWO_EDITS_READY". Do not use tools. + response: + content: TWO_EDITS_READY + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-adding-managed-asks-refreshes-both-materialized-default-and-peer-chats.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-adding-managed-asks-refreshes-both-materialized-default-and-peer-chats.yaml new file mode 100644 index 00000000000000..785fdfce61d00a --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-adding-managed-asks-refreshes-both-materialized-default-and-peer-chats.yaml @@ -0,0 +1,217 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use create exactly once to create unmanaged-default-before.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + response: + content: + - type: tool_use + id: toolcall_0 + name: create + input: + path: ${workdir}/unmanaged-default-before.txt + file_text: MANAGED_WRITE_CONTENT + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use create exactly once to create unmanaged-default-before.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + - role: assistant + content: + - type: thinking + - type: tool_use + name: create + input: + path: ${workdir}/unmanaged-default-before.txt + file_text: MANAGED_WRITE_CONTENT + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: Created file ${workdir}/unmanaged-default-before.txt with 21 characters + response: + content: Created unmanaged-default-before.txt with the exact content. + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use create exactly once to create unmanaged-peer-before.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + response: + content: + - type: tool_use + id: toolcall_1 + name: create + input: + path: ${workdir}/unmanaged-peer-before.txt + file_text: MANAGED_WRITE_CONTENT + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use create exactly once to create unmanaged-peer-before.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + - role: assistant + content: + - type: thinking + - type: tool_use + name: create + input: + path: ${workdir}/unmanaged-peer-before.txt + file_text: MANAGED_WRITE_CONTENT + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: Created file ${workdir}/unmanaged-peer-before.txt with 21 characters + response: + content: Created unmanaged-peer-before.txt with the content MANAGED_WRITE_CONTENT. + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use create exactly once to create unmanaged-default-before.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + - role: assistant + content: + - type: thinking + - type: tool_use + name: create + input: + path: ${workdir}/unmanaged-default-before.txt + file_text: MANAGED_WRITE_CONTENT + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: Created file ${workdir}/unmanaged-default-before.txt with 21 characters + - role: assistant + content: Created unmanaged-default-before.txt with the exact content. + - role: user + content: Use create exactly once to create new-managed-default.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + response: + content: + - type: tool_use + id: toolcall_2 + name: create + input: + file_text: MANAGED_WRITE_CONTENT + path: ${workdir}/new-managed-default.txt + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use create exactly once to create unmanaged-default-before.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + - role: assistant + content: + - type: thinking + - type: tool_use + name: create + input: + path: ${workdir}/unmanaged-default-before.txt + file_text: MANAGED_WRITE_CONTENT + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: Created file ${workdir}/unmanaged-default-before.txt with 21 characters + - role: assistant + content: Created unmanaged-default-before.txt with the exact content. + - role: user + content: Use create exactly once to create new-managed-default.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + - role: assistant + content: + - type: tool_use + name: create + input: + file_text: MANAGED_WRITE_CONTENT + path: ${workdir}/new-managed-default.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_2 + content: Created file ${workdir}/new-managed-default.txt with 21 characters + response: + content: Created new-managed-default.txt with the exact content. + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use create exactly once to create unmanaged-peer-before.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + - role: assistant + content: + - type: thinking + - type: tool_use + name: create + input: + path: ${workdir}/unmanaged-peer-before.txt + file_text: MANAGED_WRITE_CONTENT + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: Created file ${workdir}/unmanaged-peer-before.txt with 21 characters + - role: assistant + content: Created unmanaged-peer-before.txt with the content MANAGED_WRITE_CONTENT. + - role: user + content: Use create exactly once to create new-managed-peer.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + response: + content: + - type: tool_use + id: toolcall_3 + name: create + input: + file_text: MANAGED_WRITE_CONTENT + path: ${workdir}/new-managed-peer.txt + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use create exactly once to create unmanaged-peer-before.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + - role: assistant + content: + - type: thinking + - type: tool_use + name: create + input: + path: ${workdir}/unmanaged-peer-before.txt + file_text: MANAGED_WRITE_CONTENT + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: Created file ${workdir}/unmanaged-peer-before.txt with 21 characters + - role: assistant + content: Created unmanaged-peer-before.txt with the content MANAGED_WRITE_CONTENT. + - role: user + content: Use create exactly once to create new-managed-peer.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + - role: assistant + content: + - type: tool_use + name: create + input: + file_text: MANAGED_WRITE_CONTENT + path: ${workdir}/new-managed-peer.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_3 + content: Created file ${workdir}/new-managed-peer.txt with 21 characters + response: + content: Created new-managed-peer.txt with the content MANAGED_WRITE_CONTENT. + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-cold-host-resume-does-not-persist-a-removed-managed-ask-contribution.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-cold-host-resume-does-not-persist-a-removed-managed-ask-contribution.yaml new file mode 100644 index 00000000000000..26a5e49c99b8f1 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-cold-host-resume-does-not-persist-a-removed-managed-ask-contribution.yaml @@ -0,0 +1,110 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use create exactly once to create managed-before-clear.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + response: + content: + - type: tool_use + id: toolcall_0 + name: create + input: + path: ${workdir}/managed-before-clear.txt + file_text: MANAGED_WRITE_CONTENT + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use create exactly once to create managed-before-clear.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + - role: assistant + content: + - type: thinking + - type: tool_use + name: create + input: + path: ${workdir}/managed-before-clear.txt + file_text: MANAGED_WRITE_CONTENT + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: Created file ${workdir}/managed-before-clear.txt with 21 characters + response: + content: Done — created managed-before-clear.txt with exactly `MANAGED_WRITE_CONTENT`. + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use create exactly once to create managed-before-clear.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + - role: assistant + content: + - type: thinking + - type: tool_use + name: create + input: + path: ${workdir}/managed-before-clear.txt + file_text: MANAGED_WRITE_CONTENT + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: Created file ${workdir}/managed-before-clear.txt with 21 characters + - role: assistant + content: Done — created managed-before-clear.txt with exactly `MANAGED_WRITE_CONTENT`. + - role: user + content: Use create exactly once to create managed-after-clear.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + response: + content: + - type: tool_use + id: toolcall_1 + name: create + input: + file_text: MANAGED_WRITE_CONTENT + path: ${workdir}/managed-after-clear.txt + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use create exactly once to create managed-before-clear.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + - role: assistant + content: + - type: thinking + - type: tool_use + name: create + input: + path: ${workdir}/managed-before-clear.txt + file_text: MANAGED_WRITE_CONTENT + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: Created file ${workdir}/managed-before-clear.txt with 21 characters + - role: assistant + content: Done — created managed-before-clear.txt with exactly `MANAGED_WRITE_CONTENT`. + - role: user + content: Use create exactly once to create managed-after-clear.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + - role: assistant + content: + - type: tool_use + name: create + input: + file_text: MANAGED_WRITE_CONTENT + path: ${workdir}/managed-after-clear.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: Created file ${workdir}/managed-after-clear.txt with 21 characters + response: + content: Done — created managed-after-clear.txt with exactly `MANAGED_WRITE_CONTENT`. + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-cold-host-resume-reapplies-the-current-managed-ask-contribution.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-cold-host-resume-reapplies-the-current-managed-ask-contribution.yaml new file mode 100644 index 00000000000000..0e2b6f90c746d4 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-cold-host-resume-reapplies-the-current-managed-ask-contribution.yaml @@ -0,0 +1,110 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use create exactly once to create managed-before-restart.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + response: + content: + - type: tool_use + id: toolcall_0 + name: create + input: + path: ${workdir}/managed-before-restart.txt + file_text: MANAGED_WRITE_CONTENT + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use create exactly once to create managed-before-restart.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + - role: assistant + content: + - type: thinking + - type: tool_use + name: create + input: + path: ${workdir}/managed-before-restart.txt + file_text: MANAGED_WRITE_CONTENT + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: Created file ${workdir}/managed-before-restart.txt with 21 characters + response: + content: Created managed-before-restart.txt with the content MANAGED_WRITE_CONTENT. + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use create exactly once to create managed-before-restart.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + - role: assistant + content: + - type: thinking + - type: tool_use + name: create + input: + path: ${workdir}/managed-before-restart.txt + file_text: MANAGED_WRITE_CONTENT + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: Created file ${workdir}/managed-before-restart.txt with 21 characters + - role: assistant + content: Created managed-before-restart.txt with the content MANAGED_WRITE_CONTENT. + - role: user + content: Use create exactly once to create managed-after-restart.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + response: + content: + - type: tool_use + id: toolcall_1 + name: create + input: + path: ${workdir}/managed-after-restart.txt + file_text: MANAGED_WRITE_CONTENT + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use create exactly once to create managed-before-restart.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + - role: assistant + content: + - type: thinking + - type: tool_use + name: create + input: + path: ${workdir}/managed-before-restart.txt + file_text: MANAGED_WRITE_CONTENT + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: Created file ${workdir}/managed-before-restart.txt with 21 characters + - role: assistant + content: Created managed-before-restart.txt with the content MANAGED_WRITE_CONTENT. + - role: user + content: Use create exactly once to create managed-after-restart.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + - role: assistant + content: + - type: tool_use + name: create + input: + path: ${workdir}/managed-after-restart.txt + file_text: MANAGED_WRITE_CONTENT + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: Created file ${workdir}/managed-after-restart.txt with 21 characters + response: + content: Created managed-after-restart.txt with the content MANAGED_WRITE_CONTENT. + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-cold-session-resume-applies-revoked-managed-capture-instead-of-the-persisted-policy.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-cold-session-resume-applies-revoked-managed-capture-instead-of-the-persisted-policy.yaml new file mode 100644 index 00000000000000..b48c850a9a68bd --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-cold-session-resume-applies-revoked-managed-capture-instead-of-the-persisted-policy.yaml @@ -0,0 +1,25 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "otel-resume-before". + response: + content: otel-resume-before + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "otel-resume-before". + - role: assistant + content: otel-resume-before + - role: user + content: Reply exactly "otel-resume-after". + response: + content: otel-resume-after + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-capturecontent-denial-overrides-local-content-opt-in.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-capturecontent-denial-overrides-local-content-opt-in.yaml new file mode 100644 index 00000000000000..a6458450916764 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-capturecontent-denial-overrides-local-content-opt-in.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "otel-private-message". + response: + content: otel-private-message + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-collector-overrides-an-inherited-trace-specific-endpoint.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-collector-overrides-an-inherited-trace-specific-endpoint.yaml new file mode 100644 index 00000000000000..8ac19b2e8ce8bc --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-collector-overrides-an-inherited-trace-specific-endpoint.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "otel-endpoint-precedence". + response: + content: otel-endpoint-precedence + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-identity-denial-removes-inherited-identity-from-native-spans.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-identity-denial-removes-inherited-identity-from-native-spans.yaml new file mode 100644 index 00000000000000..866cc7e0f4880d --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-identity-denial-removes-inherited-identity-from-native-spans.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "otel-identity". + response: + content: otel-identity + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-json-protocol-overrides-inherited-generic-and-trace-specific-protobuf-protocols.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-json-protocol-overrides-inherited-generic-and-trace-specific-protobuf-protocols.yaml new file mode 100644 index 00000000000000..dc01d8f9d6da30 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-json-protocol-overrides-inherited-generic-and-trace-specific-protobuf-protocols.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "otel-protocol-precedence". + response: + content: otel-protocol-precedence + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-locked-content-capture-overrides-local-content-opt-in.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-locked-content-capture-overrides-local-content-opt-in.yaml new file mode 100644 index 00000000000000..a6458450916764 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-locked-content-capture-overrides-local-content-opt-in.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "otel-private-message". + response: + content: otel-private-message + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-prompt-capture-can-be-reenabled-after-a-denied-session.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-prompt-capture-can-be-reenabled-after-a-denied-session.yaml new file mode 100644 index 00000000000000..d5cca400c649cf --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-prompt-capture-can-be-reenabled-after-a-denied-session.yaml @@ -0,0 +1,21 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "otel-capture-denied". + response: + content: otel-capture-denied + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "otel-capture-enabled". + response: + content: otel-capture-enabled + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-prompt-capture-denial-overrides-local-content-opt-in.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-prompt-capture-denial-overrides-local-content-opt-in.yaml new file mode 100644 index 00000000000000..a6458450916764 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-prompt-capture-denial-overrides-local-content-opt-in.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "otel-private-message". + response: + content: otel-private-message + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-prompt-only-capture-overrides-local-content-opt-in.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-prompt-only-capture-overrides-local-content-opt-in.yaml new file mode 100644 index 00000000000000..a6458450916764 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-prompt-only-capture-overrides-local-content-opt-in.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "otel-private-message". + response: + content: otel-private-message + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-read-approval-remains-one-time-under-assisted.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-read-approval-remains-one-time-under-assisted.yaml new file mode 100644 index 00000000000000..94f0d984cb14da --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-read-approval-remains-one-time-under-assisted.yaml @@ -0,0 +1,104 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use view exactly once to read input.txt anew, even if you read it before. Do not use a shell. If denied, stop and reply denied. + response: + content: + - type: tool_use + id: toolcall_0 + name: view + input: + path: ${workdir}/input.txt + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use view exactly once to read input.txt anew, even if you read it before. Do not use a shell. If denied, stop and reply denied. + - role: assistant + content: + - type: thinking + - type: tool_use + name: view + input: + path: ${workdir}/input.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: MANAGED_READ_CONTENT + response: + content: 'The file `input.txt` contains: `MANAGED_READ_CONTENT`' + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use view exactly once to read input.txt anew, even if you read it before. Do not use a shell. If denied, stop and reply denied. + - role: assistant + content: + - type: thinking + - type: tool_use + name: view + input: + path: ${workdir}/input.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: MANAGED_READ_CONTENT + - role: assistant + content: 'The file `input.txt` contains: `MANAGED_READ_CONTENT`' + - role: user + content: Use view exactly once to read input.txt anew, even if you read it before. Do not use a shell. If denied, stop and reply denied. + response: + content: + - type: tool_use + id: toolcall_1 + name: view + input: + path: ${workdir}/input.txt + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use view exactly once to read input.txt anew, even if you read it before. Do not use a shell. If denied, stop and reply denied. + - role: assistant + content: + - type: thinking + - type: tool_use + name: view + input: + path: ${workdir}/input.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: MANAGED_READ_CONTENT + - role: assistant + content: 'The file `input.txt` contains: `MANAGED_READ_CONTENT`' + - role: user + content: Use view exactly once to read input.txt anew, even if you read it before. Do not use a shell. If denied, stop and reply denied. + - role: assistant + content: + - type: tool_use + name: view + input: + path: ${workdir}/input.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: MANAGED_READ_CONTENT + response: + content: 'The file `input.txt` contains: `MANAGED_READ_CONTENT`' + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-read-approval-remains-one-time-under-autoapprove.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-read-approval-remains-one-time-under-autoapprove.yaml new file mode 100644 index 00000000000000..88528b47a2f045 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-read-approval-remains-one-time-under-autoapprove.yaml @@ -0,0 +1,101 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use view exactly once to read input.txt anew, even if you read it before. Do not use a shell. If denied, stop and reply denied. + response: + content: + - type: tool_use + id: toolcall_0 + name: view + input: + path: ${workdir}/input.txt + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use view exactly once to read input.txt anew, even if you read it before. Do not use a shell. If denied, stop and reply denied. + - role: assistant + content: + - type: tool_use + name: view + input: + path: ${workdir}/input.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: MANAGED_READ_CONTENT + response: + content: 'The file content is: `MANAGED_READ_CONTENT`' + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use view exactly once to read input.txt anew, even if you read it before. Do not use a shell. If denied, stop and reply denied. + - role: assistant + content: + - type: tool_use + name: view + input: + path: ${workdir}/input.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: MANAGED_READ_CONTENT + - role: assistant + content: 'The file content is: `MANAGED_READ_CONTENT`' + - role: user + content: Use view exactly once to read input.txt anew, even if you read it before. Do not use a shell. If denied, stop and reply denied. + response: + content: + - type: tool_use + id: toolcall_1 + name: view + input: + path: ${workdir}/input.txt + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use view exactly once to read input.txt anew, even if you read it before. Do not use a shell. If denied, stop and reply denied. + - role: assistant + content: + - type: tool_use + name: view + input: + path: ${workdir}/input.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: MANAGED_READ_CONTENT + - role: assistant + content: 'The file content is: `MANAGED_READ_CONTENT`' + - role: user + content: Use view exactly once to read input.txt anew, even if you read it before. Do not use a shell. If denied, stop and reply denied. + - role: assistant + content: + - type: tool_use + name: view + input: + path: ${workdir}/input.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: MANAGED_READ_CONTENT + response: + content: 'The file content is: `MANAGED_READ_CONTENT`' + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-read-denial-blocks-the-native-tool-under-allow-all.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-read-denial-blocks-the-native-tool-under-allow-all.yaml new file mode 100644 index 00000000000000..f67c5a2c9e6161 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-read-denial-blocks-the-native-tool-under-allow-all.yaml @@ -0,0 +1,37 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use view exactly once to read input.txt anew, even if you read it before. Do not use a shell. If denied, stop and reply denied. + response: + content: + - type: tool_use + id: toolcall_0 + name: view + input: + path: ${workdir}/input.txt + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use view exactly once to read input.txt anew, even if you read it before. Do not use a shell. If denied, stop and reply denied. + - role: assistant + content: + - type: tool_use + name: view + input: + path: ${workdir}/input.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Permission to run this tool was denied due to the following rules: `Read`' + response: + content: Denied. + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-resource-attributes-override-conflicts-and-retain-unrelated-environment-attributes.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-resource-attributes-override-conflicts-and-retain-unrelated-environment-attributes.yaml new file mode 100644 index 00000000000000..c300800f83b318 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-resource-attributes-override-conflicts-and-retain-unrelated-environment-attributes.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "otel-resource-precedence". + response: + content: otel-resource-precedence + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-response-capture-denial-overrides-local-content-opt-in.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-response-capture-denial-overrides-local-content-opt-in.yaml new file mode 100644 index 00000000000000..a6458450916764 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-response-capture-denial-overrides-local-content-opt-in.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "otel-private-message". + response: + content: otel-private-message + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-response-only-capture-overrides-local-content-opt-in.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-response-only-capture-overrides-local-content-opt-in.yaml new file mode 100644 index 00000000000000..a6458450916764 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-response-only-capture-overrides-local-content-opt-in.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "otel-private-message". + response: + content: otel-private-message + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-service-identity-overrides-inherited-service-resource-attributes.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-service-identity-overrides-inherited-service-resource-attributes.yaml new file mode 100644 index 00000000000000..db0c5b475ec8e5 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-service-identity-overrides-inherited-service-resource-attributes.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "otel-service-precedence". + response: + content: otel-service-precedence + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-telemetry-disablement-overrides-an-environment-selected-collector.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-telemetry-disablement-overrides-an-environment-selected-collector.yaml new file mode 100644 index 00000000000000..80a238b948c76e --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-telemetry-disablement-overrides-an-environment-selected-collector.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "otel-disabled-message". + response: + content: otel-disabled-message + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-write-approval-remains-one-time-under-assisted.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-write-approval-remains-one-time-under-assisted.yaml new file mode 100644 index 00000000000000..2c9c64533d5345 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-write-approval-remains-one-time-under-assisted.yaml @@ -0,0 +1,107 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use create exactly once to create managed-write-first.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + response: + content: + - type: tool_use + id: toolcall_0 + name: create + input: + path: ${workdir}/managed-write-first.txt + file_text: MANAGED_WRITE_CONTENT + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use create exactly once to create managed-write-first.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + - role: assistant + content: + - type: tool_use + name: create + input: + path: ${workdir}/managed-write-first.txt + file_text: MANAGED_WRITE_CONTENT + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: Created file ${workdir}/managed-write-first.txt with 21 characters + response: + content: Created managed-write-first.txt with the exact content. + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use create exactly once to create managed-write-first.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + - role: assistant + content: + - type: tool_use + name: create + input: + path: ${workdir}/managed-write-first.txt + file_text: MANAGED_WRITE_CONTENT + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: Created file ${workdir}/managed-write-first.txt with 21 characters + - role: assistant + content: Created managed-write-first.txt with the exact content. + - role: user + content: Use create exactly once to create managed-write-second.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + response: + content: + - type: tool_use + id: toolcall_1 + name: create + input: + path: ${workdir}/managed-write-second.txt + file_text: MANAGED_WRITE_CONTENT + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use create exactly once to create managed-write-first.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + - role: assistant + content: + - type: tool_use + name: create + input: + path: ${workdir}/managed-write-first.txt + file_text: MANAGED_WRITE_CONTENT + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: Created file ${workdir}/managed-write-first.txt with 21 characters + - role: assistant + content: Created managed-write-first.txt with the exact content. + - role: user + content: Use create exactly once to create managed-write-second.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + - role: assistant + content: + - type: tool_use + name: create + input: + path: ${workdir}/managed-write-second.txt + file_text: MANAGED_WRITE_CONTENT + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: Created file ${workdir}/managed-write-second.txt with 21 characters + response: + content: Created managed-write-second.txt with the exact content. + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-write-approval-remains-one-time-under-autoapprove.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-write-approval-remains-one-time-under-autoapprove.yaml new file mode 100644 index 00000000000000..39112e53015637 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-write-approval-remains-one-time-under-autoapprove.yaml @@ -0,0 +1,110 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use create exactly once to create managed-write-first.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + response: + content: + - type: tool_use + id: toolcall_0 + name: create + input: + path: ${workdir}/managed-write-first.txt + file_text: MANAGED_WRITE_CONTENT + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use create exactly once to create managed-write-first.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + - role: assistant + content: + - type: thinking + - type: tool_use + name: create + input: + path: ${workdir}/managed-write-first.txt + file_text: MANAGED_WRITE_CONTENT + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: Created file ${workdir}/managed-write-first.txt with 21 characters + response: + content: Created managed-write-first.txt with the content MANAGED_WRITE_CONTENT. + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use create exactly once to create managed-write-first.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + - role: assistant + content: + - type: thinking + - type: tool_use + name: create + input: + path: ${workdir}/managed-write-first.txt + file_text: MANAGED_WRITE_CONTENT + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: Created file ${workdir}/managed-write-first.txt with 21 characters + - role: assistant + content: Created managed-write-first.txt with the content MANAGED_WRITE_CONTENT. + - role: user + content: Use create exactly once to create managed-write-second.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + response: + content: + - type: tool_use + id: toolcall_1 + name: create + input: + file_text: MANAGED_WRITE_CONTENT + path: ${workdir}/managed-write-second.txt + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use create exactly once to create managed-write-first.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + - role: assistant + content: + - type: thinking + - type: tool_use + name: create + input: + path: ${workdir}/managed-write-first.txt + file_text: MANAGED_WRITE_CONTENT + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: Created file ${workdir}/managed-write-first.txt with 21 characters + - role: assistant + content: Created managed-write-first.txt with the content MANAGED_WRITE_CONTENT. + - role: user + content: Use create exactly once to create managed-write-second.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + - role: assistant + content: + - type: tool_use + name: create + input: + file_text: MANAGED_WRITE_CONTENT + path: ${workdir}/managed-write-second.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: Created file ${workdir}/managed-write-second.txt with 21 characters + response: + content: Created managed-write-second.txt with the content MANAGED_WRITE_CONTENT. + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-write-denial-blocks-the-native-tool-under-allow-all.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-write-denial-blocks-the-native-tool-under-allow-all.yaml new file mode 100644 index 00000000000000..ebcbf82c84e7fc --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-write-denial-blocks-the-native-tool-under-allow-all.yaml @@ -0,0 +1,40 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use create exactly once to create managed-write-blocked.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + response: + content: + - type: tool_use + id: toolcall_0 + name: create + input: + path: ${workdir}/managed-write-blocked.txt + file_text: MANAGED_WRITE_CONTENT + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use create exactly once to create managed-write-blocked.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + - role: assistant + content: + - type: thinking + - type: tool_use + name: create + input: + path: ${workdir}/managed-write-blocked.txt + file_text: MANAGED_WRITE_CONTENT + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Permission to run this tool was denied due to the following rules: `Write`' + response: + content: denied + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-write-denial-prevents-mutation-under-assisted.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-write-denial-prevents-mutation-under-assisted.yaml new file mode 100644 index 00000000000000..987c81550d322f --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-write-denial-prevents-mutation-under-assisted.yaml @@ -0,0 +1,39 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use create exactly once to create managed-write-denied.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + response: + content: + - type: tool_use + id: toolcall_0 + name: create + input: + path: ${workdir}/managed-write-denied.txt + file_text: MANAGED_WRITE_CONTENT + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use create exactly once to create managed-write-denied.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + - role: assistant + content: + - type: tool_use + name: create + input: + path: ${workdir}/managed-write-denied.txt + file_text: MANAGED_WRITE_CONTENT + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'The user rejected this tool call. User feedback: The user denied permission.' + response: + content: denied + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-write-denial-prevents-mutation-under-autoapprove.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-write-denial-prevents-mutation-under-autoapprove.yaml new file mode 100644 index 00000000000000..987c81550d322f --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-managed-write-denial-prevents-mutation-under-autoapprove.yaml @@ -0,0 +1,39 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use create exactly once to create managed-write-denied.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + response: + content: + - type: tool_use + id: toolcall_0 + name: create + input: + path: ${workdir}/managed-write-denied.txt + file_text: MANAGED_WRITE_CONTENT + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use create exactly once to create managed-write-denied.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + - role: assistant + content: + - type: tool_use + name: create + input: + path: ${workdir}/managed-write-denied.txt + file_text: MANAGED_WRITE_CONTENT + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'The user rejected this tool call. User feedback: The user denied permission.' + response: + content: denied + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-mcp-side-channel-regression-coverage-isolates-a-concurrent-server-error-from-successful-tool-and-resource-responses.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-mcp-side-channel-regression-coverage-isolates-a-concurrent-server-error-from-successful-tool-and-resource-responses.yaml new file mode 100644 index 00000000000000..be942c1205d7f8 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-mcp-side-channel-regression-coverage-isolates-a-concurrent-server-error-from-successful-tool-and-resource-responses.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + response: + content: READY + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-mcp-side-channel-regression-coverage-preserves-multiple-resource-contents-with-distinct-uris-and-encodings.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-mcp-side-channel-regression-coverage-preserves-multiple-resource-contents-with-distinct-uris-and-encodings.yaml new file mode 100644 index 00000000000000..be942c1205d7f8 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-mcp-side-channel-regression-coverage-preserves-multiple-resource-contents-with-distinct-uris-and-encodings.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + response: + content: READY + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-mcp-side-channel-regression-coverage-preserves-ordered-text-image-and-embedded-resource-tool-content.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-mcp-side-channel-regression-coverage-preserves-ordered-text-image-and-embedded-resource-tool-content.yaml new file mode 100644 index 00000000000000..be942c1205d7f8 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-mcp-side-channel-regression-coverage-preserves-ordered-text-image-and-embedded-resource-tool-content.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + response: + content: READY + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-mcp-side-channel-regression-coverage-preserves-structured-tool-results-and-private-result-metadata.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-mcp-side-channel-regression-coverage-preserves-structured-tool-results-and-private-result-metadata.yaml new file mode 100644 index 00000000000000..be942c1205d7f8 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-mcp-side-channel-regression-coverage-preserves-structured-tool-results-and-private-result-metadata.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + response: + content: READY + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-mcp-side-channel-regression-coverage-refreshes-schemas-for-an-existing-tool-without-a-name-change.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-mcp-side-channel-regression-coverage-refreshes-schemas-for-an-existing-tool-without-a-name-change.yaml new file mode 100644 index 00000000000000..be942c1205d7f8 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-mcp-side-channel-regression-coverage-refreshes-schemas-for-an-existing-tool-without-a-name-change.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + response: + content: READY + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-mcp-side-channel-regression-coverage-refreshes-the-tool-catalog-after-a-real-server-adds-a-callable-tool.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-mcp-side-channel-regression-coverage-refreshes-the-tool-catalog-after-a-real-server-adds-a-callable-tool.yaml new file mode 100644 index 00000000000000..be942c1205d7f8 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-mcp-side-channel-regression-coverage-refreshes-the-tool-catalog-after-a-real-server-adds-a-callable-tool.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + response: + content: READY + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-mcp-side-channel-regression-coverage-removes-a-tool-from-a-refreshed-catalog-while-retaining-healthy-calls.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-mcp-side-channel-regression-coverage-removes-a-tool-from-a-refreshed-catalog-while-retaining-healthy-calls.yaml new file mode 100644 index 00000000000000..be942c1205d7f8 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-mcp-side-channel-regression-coverage-removes-a-tool-from-a-refreshed-catalog-while-retaining-healthy-calls.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + response: + content: READY + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-mcp-side-channel-regression-coverage-retries-the-same-resource-after-a-transient-real-server-error.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-mcp-side-channel-regression-coverage-retries-the-same-resource-after-a-transient-real-server-error.yaml new file mode 100644 index 00000000000000..be942c1205d7f8 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-mcp-side-channel-regression-coverage-retries-the-same-resource-after-a-transient-real-server-error.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + response: + content: READY + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-mcp-side-channel-regression-coverage-returns-an-empty-successful-tool-result-without-fabricating-text.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-mcp-side-channel-regression-coverage-returns-an-empty-successful-tool-result-without-fabricating-text.yaml new file mode 100644 index 00000000000000..be942c1205d7f8 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-mcp-side-channel-regression-coverage-returns-an-empty-successful-tool-result-without-fabricating-text.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + response: + content: READY + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-mcp-side-channel-regression-coverage-returns-resource-links-that-can-be-read-on-the-same-advertised-channel.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-mcp-side-channel-regression-coverage-returns-resource-links-that-can-be-read-on-the-same-advertised-channel.yaml new file mode 100644 index 00000000000000..be942c1205d7f8 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-mcp-side-channel-regression-coverage-returns-resource-links-that-can-be-read-on-the-same-advertised-channel.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + response: + content: READY + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-new-sessions-revoke-managed-content-capture-without-restarting.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-new-sessions-revoke-managed-content-capture-without-restarting.yaml new file mode 100644 index 00000000000000..a10c89818ea46a --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-new-sessions-revoke-managed-content-capture-without-restarting.yaml @@ -0,0 +1,21 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "otel-before-revocation". + response: + content: otel-before-revocation + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "otel-after-revocation". + response: + content: otel-after-revocation + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-new-sessions-route-managed-telemetry-to-different-collectors-without-restarting.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-new-sessions-route-managed-telemetry-to-different-collectors-without-restarting.yaml new file mode 100644 index 00000000000000..743c9f570fcf8e --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-new-sessions-route-managed-telemetry-to-different-collectors-without-restarting.yaml @@ -0,0 +1,21 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "otel-collector-first". + response: + content: otel-collector-first + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "otel-collector-second". + response: + content: otel-collector-second + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-a-first-turn-fork-excludes-later-source-context.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-a-first-turn-fork-excludes-later-source-context.yaml new file mode 100644 index 00000000000000..8aeefce50bca97 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-a-first-turn-fork-excludes-later-source-context.yaml @@ -0,0 +1,38 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember FIRST_FORK_INCLUDED. Reply exactly "FIRST_READY". + response: + content: FIRST_READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember FIRST_FORK_INCLUDED. Reply exactly "FIRST_READY". + - role: assistant + content: FIRST_READY + - role: user + content: Remember LATER_FORK_EXCLUDED. Reply exactly "LATER_READY". + response: + content: LATER_READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember FIRST_FORK_INCLUDED. Reply exactly "FIRST_READY". + - role: assistant + content: FIRST_READY + - role: user + content: Reply with the code word from the source conversation. Do not use tools. + response: + content: FIRST_FORK_INCLUDED + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-a-fork-resumes-its-own-branch-after-the-source-advances.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-a-fork-resumes-its-own-branch-after-the-source-advances.yaml new file mode 100644 index 00000000000000..dfabb5b9181161 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-a-fork-resumes-its-own-branch-after-the-source-advances.yaml @@ -0,0 +1,72 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember SHARED_FORK_PREFIX. Reply exactly "PREFIX_READY". + response: + content: PREFIX_READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember SHARED_FORK_PREFIX. Reply exactly "PREFIX_READY". + - role: assistant + content: PREFIX_READY + - role: user + content: Remember FORK_BRANCH_ONLY. Reply exactly "BRANCH_READY". + response: + content: BRANCH_READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember SHARED_FORK_PREFIX. Reply exactly "PREFIX_READY". + - role: assistant + content: PREFIX_READY + - role: user + content: Remember SOURCE_ADVANCE_ONLY. Reply exactly "SOURCE_ADVANCED". + response: + content: SOURCE_ADVANCED + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember SHARED_FORK_PREFIX. Reply exactly "PREFIX_READY". + - role: assistant + content: PREFIX_READY + - role: user + content: Remember FORK_BRANCH_ONLY. Reply exactly "BRANCH_READY". + - role: assistant + content: BRANCH_READY + - role: user + content: Reply exactly "BRANCH_RESUMED". Do not use tools. + response: + content: BRANCH_RESUMED + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember SHARED_FORK_PREFIX. Reply exactly "PREFIX_READY". + - role: assistant + content: PREFIX_READY + - role: user + content: Remember SOURCE_ADVANCE_ONLY. Reply exactly "SOURCE_ADVANCED". + - role: assistant + content: SOURCE_ADVANCED + - role: user + content: Reply exactly "SOURCE_RESUMED". Do not use tools. + response: + content: SOURCE_RESUMED + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-a-middle-turn-fork-includes-completed-tools-but-excludes-the-next-turn.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-a-middle-turn-fork-includes-completed-tools-but-excludes-the-next-turn.yaml new file mode 100644 index 00000000000000..ed182b6a2798e3 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-a-middle-turn-fork-includes-completed-tools-but-excludes-the-next-turn.yaml @@ -0,0 +1,118 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember MIDDLE_FORK_SEED. Reply exactly "SEED_READY". + response: + content: SEED_READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember MIDDLE_FORK_SEED. Reply exactly "SEED_READY". + - role: assistant + content: SEED_READY + - role: user + content: Use your file editing tool exactly once to replace MIDDLE_BEFORE with MIDDLE_AFTER in middle.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + response: + content: + - type: tool_use + id: toolcall_0 + name: edit + input: + path: ${workdir}/middle.txt + old_str: MIDDLE_BEFORE + new_str: MIDDLE_AFTER + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember MIDDLE_FORK_SEED. Reply exactly "SEED_READY". + - role: assistant + content: SEED_READY + - role: user + content: Use your file editing tool exactly once to replace MIDDLE_BEFORE with MIDDLE_AFTER in middle.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: + - type: tool_use + name: edit + input: + path: ${workdir}/middle.txt + old_str: MIDDLE_BEFORE + new_str: MIDDLE_AFTER + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: File ${workdir}/middle.txt updated with changes. + response: + content: EDIT_DONE + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember MIDDLE_FORK_SEED. Reply exactly "SEED_READY". + - role: assistant + content: SEED_READY + - role: user + content: Use your file editing tool exactly once to replace MIDDLE_BEFORE with MIDDLE_AFTER in middle.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: + - type: tool_use + name: edit + input: + path: ${workdir}/middle.txt + old_str: MIDDLE_BEFORE + new_str: MIDDLE_AFTER + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: File ${workdir}/middle.txt updated with changes. + - role: assistant + content: EDIT_DONE + - role: user + content: Remember AFTER_BOUNDARY_EXCLUDED. Reply exactly "EXCLUDED_READY". + response: + content: EXCLUDED_READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember MIDDLE_FORK_SEED. Reply exactly "SEED_READY". + - role: assistant + content: SEED_READY + - role: user + content: Use your file editing tool exactly once to replace MIDDLE_BEFORE with MIDDLE_AFTER in middle.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: + - type: tool_use + name: edit + input: + path: ${workdir}/middle.txt + old_str: MIDDLE_BEFORE + new_str: MIDDLE_AFTER + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: File ${workdir}/middle.txt updated with changes. + - role: assistant + content: EDIT_DONE + - role: user + content: Reply exactly "FORK_HISTORY_READY". Do not use tools. + response: + content: FORK_HISTORY_READY + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-a-new-peer-chat-receives-the-current-client-tool-definitions.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-a-new-peer-chat-receives-the-current-client-tool-definitions.yaml new file mode 100644 index 00000000000000..ad2e2373e0b1f5 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-a-new-peer-chat-receives-the-current-client-tool-definitions.yaml @@ -0,0 +1,46 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + response: + content: READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call peer_probe exactly once with label set to peer. + response: + content: + - type: tool_use + id: toolcall_0 + name: peer_probe + input: + label: peer + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call peer_probe exactly once with label set to peer. + - role: assistant + content: + - type: tool_use + name: peer_probe + input: + label: peer + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: LIVE_PROBE_RESULT + response: + content: 'Probe result: `LIVE_PROBE_RESULT`' + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-a-restored-peer-can-be-forked-without-changing-its-source-history.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-a-restored-peer-can-be-forked-without-changing-its-source-history.yaml new file mode 100644 index 00000000000000..d437884c2e773b --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-a-restored-peer-can-be-forked-without-changing-its-source-history.yaml @@ -0,0 +1,101 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember DEFAULT_FORK_EXCLUDED. Reply exactly "DEFAULT_READY". + response: + content: DEFAULT_READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember PEER_FORK_INCLUDED. Reply exactly "PEER_READY". + response: + content: PEER_READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember PEER_FORK_INCLUDED. Reply exactly "PEER_READY". + - role: assistant + content: PEER_READY + - role: user + content: Remember PEER_LATER_EXCLUDED. Reply exactly "PEER_LATER_READY". + response: + content: PEER_LATER_READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember PEER_FORK_INCLUDED. Reply exactly "PEER_READY". + - role: assistant + content: PEER_READY + - role: user + content: Reply exactly "FORK_CONTINUED". Do not use tools. + response: + content: FORK_CONTINUED + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember PEER_FORK_INCLUDED. Reply exactly "PEER_READY". + - role: assistant + content: PEER_READY + - role: user + content: Remember PEER_LATER_EXCLUDED. Reply exactly "PEER_LATER_READY". + - role: assistant + content: PEER_LATER_READY + - role: user + content: Use your file editing tool exactly once to replace SOURCE_BEFORE with SOURCE_AFTER in source-continues.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + response: + content: + - type: tool_use + id: toolcall_0 + name: edit + input: + path: ${workdir}/source-continues.txt + old_str: SOURCE_BEFORE + new_str: SOURCE_AFTER + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember PEER_FORK_INCLUDED. Reply exactly "PEER_READY". + - role: assistant + content: PEER_READY + - role: user + content: Remember PEER_LATER_EXCLUDED. Reply exactly "PEER_LATER_READY". + - role: assistant + content: PEER_LATER_READY + - role: user + content: Use your file editing tool exactly once to replace SOURCE_BEFORE with SOURCE_AFTER in source-continues.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: + - type: tool_use + name: edit + input: + path: ${workdir}/source-continues.txt + old_str: SOURCE_BEFORE + new_str: SOURCE_AFTER + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: File ${workdir}/source-continues.txt updated with changes. + response: + content: EDIT_DONE + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-an-unstarted-peer-can-execute-its-first-tool-turn-after-restart.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-an-unstarted-peer-can-execute-its-first-tool-turn-after-restart.yaml new file mode 100644 index 00000000000000..adf207bf9f9b3e --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-an-unstarted-peer-can-execute-its-first-tool-turn-after-restart.yaml @@ -0,0 +1,59 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember DEFAULT_NOT_IN_PEER. Reply exactly "READY". + response: + content: READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember SIBLING_NOT_IN_PEER. Reply exactly "READY". + response: + content: READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use your file editing tool exactly once to replace FIRST_BEFORE with FIRST_AFTER in first-peer.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + response: + content: + - type: tool_use + id: toolcall_0 + name: edit + input: + path: ${workdir}/first-peer.txt + old_str: FIRST_BEFORE + new_str: FIRST_AFTER + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use your file editing tool exactly once to replace FIRST_BEFORE with FIRST_AFTER in first-peer.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: + - type: tool_use + name: edit + input: + path: ${workdir}/first-peer.txt + old_str: FIRST_BEFORE + new_str: FIRST_AFTER + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: File ${workdir}/first-peer.txt updated with changes. + response: + content: EDIT_DONE + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-catalog-refresh-does-not-promote-restored-peer-backings-to-sessions.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-catalog-refresh-does-not-promote-restored-peer-backings-to-sessions.yaml new file mode 100644 index 00000000000000..eb9713ef8d2a77 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-catalog-refresh-does-not-promote-restored-peer-backings-to-sessions.yaml @@ -0,0 +1,34 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "CATALOG_READY". + response: + content: CATALOG_READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember CATALOG_PEER_ONLY. Reply exactly "PEER_READY". + response: + content: PEER_READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember CATALOG_PEER_ONLY. Reply exactly "PEER_READY". + - role: assistant + content: PEER_READY + - role: user + content: Reply exactly "PEER_CONTINUED". Do not use tools. + response: + content: PEER_CONTINUED + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-client-tool-description-replacement-reaches-the-next-model-request.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-client-tool-description-replacement-reaches-the-next-model-request.yaml new file mode 100644 index 00000000000000..c5cf10b8840ff5 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-client-tool-description-replacement-reaches-the-next-model-request.yaml @@ -0,0 +1,120 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + response: + content: READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + - role: assistant + content: READY + - role: user + content: Call description_probe exactly once and report its result. + response: + content: + - type: tool_use + id: toolcall_0 + name: description_probe + input: {} + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + - role: assistant + content: READY + - role: user + content: Call description_probe exactly once and report its result. + - role: assistant + content: + - type: tool_use + name: description_probe + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: LIVE_PROBE_RESULT + response: + content: 'The result was: **LIVE_PROBE_RESULT**' + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + - role: assistant + content: READY + - role: user + content: Call description_probe exactly once and report its result. + - role: assistant + content: + - type: tool_use + name: description_probe + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: LIVE_PROBE_RESULT + - role: assistant + content: 'The result was: **LIVE_PROBE_RESULT**' + - role: user + content: Call description_probe exactly once using its current definition. + response: + content: + - type: tool_use + id: toolcall_1 + name: description_probe + input: {} + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + - role: assistant + content: READY + - role: user + content: Call description_probe exactly once and report its result. + - role: assistant + content: + - type: tool_use + name: description_probe + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: LIVE_PROBE_RESULT + - role: assistant + content: 'The result was: **LIVE_PROBE_RESULT**' + - role: user + content: Call description_probe exactly once using its current definition. + - role: assistant + content: + - type: tool_use + name: description_probe + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: LIVE_PROBE_RESULT + response: + content: 'The result was: **LIVE_PROBE_RESULT**' + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-client-tool-empty-results-permit-a-subsequent-successful-call.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-client-tool-empty-results-permit-a-subsequent-successful-call.yaml new file mode 100644 index 00000000000000..f0677278b44b8d --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-client-tool-empty-results-permit-a-subsequent-successful-call.yaml @@ -0,0 +1,123 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + response: + content: READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + - role: assistant + content: READY + - role: user + content: Call result_probe exactly once. If it fails or is empty, stop and report that without retrying. + response: + content: + - type: tool_use + id: toolcall_0 + name: result_probe + input: {} + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + - role: assistant + content: READY + - role: user + content: Call result_probe exactly once. If it fails or is empty, stop and report that without retrying. + - role: assistant + content: + - type: thinking + - type: tool_use + name: result_probe + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: + response: + content: The `result_probe` call returned empty. Stopping as instructed — no retry performed. + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + - role: assistant + content: READY + - role: user + content: Call result_probe exactly once. If it fails or is empty, stop and report that without retrying. + - role: assistant + content: + - type: thinking + - type: tool_use + name: result_probe + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: + - role: assistant + content: The `result_probe` call returned empty. Stopping as instructed — no retry performed. + - role: user + content: Call result_probe exactly once again and report its new result. + response: + content: + - type: tool_use + id: toolcall_1 + name: result_probe + input: {} + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + - role: assistant + content: READY + - role: user + content: Call result_probe exactly once. If it fails or is empty, stop and report that without retrying. + - role: assistant + content: + - type: thinking + - type: tool_use + name: result_probe + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: + - role: assistant + content: The `result_probe` call returned empty. Stopping as instructed — no retry performed. + - role: user + content: Call result_probe exactly once again and report its new result. + - role: assistant + content: + - type: tool_use + name: result_probe + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: LIVE_PROBE_RESULT + response: + content: 'The `result_probe` call returned a new result: **LIVE_PROBE_RESULT**' + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-client-tool-failed-results-permit-a-subsequent-successful-call.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-client-tool-failed-results-permit-a-subsequent-successful-call.yaml new file mode 100644 index 00000000000000..e8db0c8ed53942 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-client-tool-failed-results-permit-a-subsequent-successful-call.yaml @@ -0,0 +1,123 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + response: + content: READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + - role: assistant + content: READY + - role: user + content: Call result_probe exactly once. If it fails or is empty, stop and report that without retrying. + response: + content: + - type: tool_use + id: toolcall_0 + name: result_probe + input: {} + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + - role: assistant + content: READY + - role: user + content: Call result_probe exactly once. If it fails or is empty, stop and report that without retrying. + - role: assistant + content: + - type: thinking + - type: tool_use + name: result_probe + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: EXPECTED_PROBE_FAILURE + response: + content: 'The result_probe call returned an error: **EXPECTED_PROBE_FAILURE**. As instructed, I''m stopping here and reporting this failure without retrying.' + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + - role: assistant + content: READY + - role: user + content: Call result_probe exactly once. If it fails or is empty, stop and report that without retrying. + - role: assistant + content: + - type: thinking + - type: tool_use + name: result_probe + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: EXPECTED_PROBE_FAILURE + - role: assistant + content: 'The result_probe call returned an error: **EXPECTED_PROBE_FAILURE**. As instructed, I''m stopping here and reporting this failure without retrying.' + - role: user + content: Call result_probe exactly once again and report its new result. + response: + content: + - type: tool_use + id: toolcall_1 + name: result_probe + input: {} + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + - role: assistant + content: READY + - role: user + content: Call result_probe exactly once. If it fails or is empty, stop and report that without retrying. + - role: assistant + content: + - type: thinking + - type: tool_use + name: result_probe + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: EXPECTED_PROBE_FAILURE + - role: assistant + content: 'The result_probe call returned an error: **EXPECTED_PROBE_FAILURE**. As instructed, I''m stopping here and reporting this failure without retrying.' + - role: user + content: Call result_probe exactly once again and report its new result. + - role: assistant + content: + - type: tool_use + name: result_probe + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: LIVE_PROBE_RESULT + response: + content: 'The result_probe call returned: **LIVE_PROBE_RESULT**' + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-client-tool-multiple-text-parts-results-permit-a-subsequent-successful-call.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-client-tool-multiple-text-parts-results-permit-a-subsequent-successful-call.yaml new file mode 100644 index 00000000000000..1b1bfec13a6d8e --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-client-tool-multiple-text-parts-results-permit-a-subsequent-successful-call.yaml @@ -0,0 +1,126 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + response: + content: READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + - role: assistant + content: READY + - role: user + content: Call result_probe exactly once. If it fails or is empty, stop and report that without retrying. + response: + content: + - type: tool_use + id: toolcall_0 + name: result_probe + input: {} + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + - role: assistant + content: READY + - role: user + content: Call result_probe exactly once. If it fails or is empty, stop and report that without retrying. + - role: assistant + content: + - type: tool_use + name: result_probe + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: |- + LEFT_RESULT + RIGHT_RESULT + response: + content: 'The probe returned a result: "LEFT_RESULT RIGHT_RESULT"' + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + - role: assistant + content: READY + - role: user + content: Call result_probe exactly once. If it fails or is empty, stop and report that without retrying. + - role: assistant + content: + - type: tool_use + name: result_probe + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: |- + LEFT_RESULT + RIGHT_RESULT + - role: assistant + content: 'The probe returned a result: "LEFT_RESULT RIGHT_RESULT"' + - role: user + content: Call result_probe exactly once again and report its new result. + response: + content: + - type: tool_use + id: toolcall_1 + name: result_probe + input: {} + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + - role: assistant + content: READY + - role: user + content: Call result_probe exactly once. If it fails or is empty, stop and report that without retrying. + - role: assistant + content: + - type: tool_use + name: result_probe + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: |- + LEFT_RESULT + RIGHT_RESULT + - role: assistant + content: 'The probe returned a result: "LEFT_RESULT RIGHT_RESULT"' + - role: user + content: Call result_probe exactly once again and report its new result. + - role: assistant + content: + - type: tool_use + name: result_probe + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: LIVE_PROBE_RESULT + response: + content: 'The probe returned a new result: "LIVE_PROBE_RESULT"' + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-client-tool-schema-replacement-reaches-a-live-provider-session.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-client-tool-schema-replacement-reaches-a-live-provider-session.yaml new file mode 100644 index 00000000000000..96558d333b067a --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-client-tool-schema-replacement-reaches-a-live-provider-session.yaml @@ -0,0 +1,127 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + response: + content: READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + - role: assistant + content: READY + - role: user + content: Call schema_probe exactly once with oldField set to before and report its result. + response: + content: + - type: tool_use + id: toolcall_0 + name: schema_probe + input: + oldField: before + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + - role: assistant + content: READY + - role: user + content: Call schema_probe exactly once with oldField set to before and report its result. + - role: assistant + content: + - type: tool_use + name: schema_probe + input: + oldField: before + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: LIVE_PROBE_RESULT + response: + content: 'The result of calling `schema_probe` with `oldField` set to "before" is: **LIVE_PROBE_RESULT**' + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + - role: assistant + content: READY + - role: user + content: Call schema_probe exactly once with oldField set to before and report its result. + - role: assistant + content: + - type: tool_use + name: schema_probe + input: + oldField: before + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: LIVE_PROBE_RESULT + - role: assistant + content: 'The result of calling `schema_probe` with `oldField` set to "before" is: **LIVE_PROBE_RESULT**' + - role: user + content: Call schema_probe exactly once with newField set to after. Use the current schema, not the previous one. + response: + content: + - type: tool_use + id: toolcall_1 + name: schema_probe + input: + newField: after + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + - role: assistant + content: READY + - role: user + content: Call schema_probe exactly once with oldField set to before and report its result. + - role: assistant + content: + - type: tool_use + name: schema_probe + input: + oldField: before + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: LIVE_PROBE_RESULT + - role: assistant + content: 'The result of calling `schema_probe` with `oldField` set to "before" is: **LIVE_PROBE_RESULT**' + - role: user + content: Call schema_probe exactly once with newField set to after. Use the current schema, not the previous one. + - role: assistant + content: + - type: thinking + - type: tool_use + name: schema_probe + input: + newField: after + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: LIVE_PROBE_RESULT + response: + content: 'The result of calling `schema_probe` with `newField` set to "after" is: **LIVE_PROBE_RESULT**' + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-client-tools-added-after-materialization-execute-on-the-next-turn.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-client-tools-added-after-materialization-execute-on-the-next-turn.yaml new file mode 100644 index 00000000000000..d029298d25efe3 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-client-tools-added-after-materialization-execute-on-the-next-turn.yaml @@ -0,0 +1,53 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + response: + content: READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + - role: assistant + content: READY + - role: user + content: Call added_probe exactly once and report its result. + response: + content: + - type: tool_use + id: toolcall_0 + name: added_probe + input: {} + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + - role: assistant + content: READY + - role: user + content: Call added_probe exactly once and report its result. + - role: assistant + content: + - type: thinking + - type: tool_use + name: added_probe + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: LIVE_PROBE_RESULT + response: + content: 'The result of `added_probe` is: **LIVE_PROBE_RESULT**' + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-cold-restored-peers-retain-independent-provider-contexts.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-cold-restored-peers-retain-independent-provider-contexts.yaml new file mode 100644 index 00000000000000..024db16dedfbc8 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-cold-restored-peers-retain-independent-provider-contexts.yaml @@ -0,0 +1,56 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "DEFAULT_READY". + response: + content: DEFAULT_READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember PEER_RED_ONLY. Reply exactly "RED_READY". + response: + content: RED_READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember PEER_BLUE_ONLY. Reply exactly "BLUE_READY". + response: + content: BLUE_READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember PEER_BLUE_ONLY. Reply exactly "BLUE_READY". + - role: assistant + content: BLUE_READY + - role: user + content: Reply with the code word I gave this chat. Do not use tools. + response: + content: PEER_BLUE_ONLY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember PEER_RED_ONLY. Reply exactly "RED_READY". + - role: assistant + content: RED_READY + - role: user + content: Reply with the code word I gave this chat. Do not use tools. + response: + content: PEER_RED_ONLY + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-cold-resume-binds-client-tool-execution-to-the-current-client.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-cold-resume-binds-client-tool-execution-to-the-current-client.yaml new file mode 100644 index 00000000000000..13ca5f428867b7 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-cold-resume-binds-client-tool-execution-to-the-current-client.yaml @@ -0,0 +1,120 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + response: + content: READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + - role: assistant + content: READY + - role: user + content: Call resume_probe exactly once and report its result. + response: + content: + - type: tool_use + id: toolcall_0 + name: resume_probe + input: {} + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + - role: assistant + content: READY + - role: user + content: Call resume_probe exactly once and report its result. + - role: assistant + content: + - type: tool_use + name: resume_probe + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: LIVE_PROBE_RESULT + response: + content: 'The result of `resume_probe` is: **LIVE_PROBE_RESULT**' + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + - role: assistant + content: READY + - role: user + content: Call resume_probe exactly once and report its result. + - role: assistant + content: + - type: tool_use + name: resume_probe + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: LIVE_PROBE_RESULT + - role: assistant + content: 'The result of `resume_probe` is: **LIVE_PROBE_RESULT**' + - role: user + content: Call resume_probe exactly once again and report its result. + response: + content: + - type: tool_use + id: toolcall_1 + name: resume_probe + input: {} + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + - role: assistant + content: READY + - role: user + content: Call resume_probe exactly once and report its result. + - role: assistant + content: + - type: tool_use + name: resume_probe + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: LIVE_PROBE_RESULT + - role: assistant + content: 'The result of `resume_probe` is: **LIVE_PROBE_RESULT**' + - role: user + content: Call resume_probe exactly once again and report its result. + - role: assistant + content: + - type: tool_use + name: resume_probe + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: LIVE_PROBE_RESULT + response: + content: 'The result of `resume_probe` is: **LIVE_PROBE_RESULT**' + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-cold-resume-retains-ordered-user-and-assistant-context.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-cold-resume-retains-ordered-user-and-assistant-context.yaml new file mode 100644 index 00000000000000..13b54c5b4ac655 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-cold-resume-retains-ordered-user-and-assistant-context.yaml @@ -0,0 +1,42 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember the first entry ORDERED_ALPHA. Reply exactly "ALPHA_READY". + response: + content: ALPHA_READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember the first entry ORDERED_ALPHA. Reply exactly "ALPHA_READY". + - role: assistant + content: ALPHA_READY + - role: user + content: Remember the second entry ORDERED_BETA without replacing the first. Reply exactly "BETA_READY". + response: + content: BETA_READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember the first entry ORDERED_ALPHA. Reply exactly "ALPHA_READY". + - role: assistant + content: ALPHA_READY + - role: user + content: Remember the second entry ORDERED_BETA without replacing the first. Reply exactly "BETA_READY". + - role: assistant + content: BETA_READY + - role: user + content: Reply with both entries in the order I gave them. Do not use tools. + response: + content: ORDERED_ALPHA, ORDERED_BETA + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-completed-file-edit-remains-paired-with-its-result-after-resume.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-completed-file-edit-remains-paired-with-its-result-after-resume.yaml new file mode 100644 index 00000000000000..3bad454c325e4a --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-completed-file-edit-remains-paired-with-its-result-after-resume.yaml @@ -0,0 +1,67 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use your file editing tool exactly once to replace PAIR_BEFORE with PAIR_AFTER in paired.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + response: + content: + - type: tool_use + id: toolcall_0 + name: edit + input: + path: ${workdir}/paired.txt + old_str: PAIR_BEFORE + new_str: PAIR_AFTER + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use your file editing tool exactly once to replace PAIR_BEFORE with PAIR_AFTER in paired.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: + - type: tool_use + name: edit + input: + path: ${workdir}/paired.txt + old_str: PAIR_BEFORE + new_str: PAIR_AFTER + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: File ${workdir}/paired.txt updated with changes. + response: + content: EDIT_DONE + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use your file editing tool exactly once to replace PAIR_BEFORE with PAIR_AFTER in paired.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: + - type: tool_use + name: edit + input: + path: ${workdir}/paired.txt + old_str: PAIR_BEFORE + new_str: PAIR_AFTER + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: File ${workdir}/paired.txt updated with changes. + - role: assistant + content: EDIT_DONE + - role: user + content: Reply exactly "HISTORY_READY". Do not use tools. + response: + content: HISTORY_READY + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-identical-prompts-remain-separate-completed-turns-after-resume.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-identical-prompts-remain-separate-completed-turns-after-resume.yaml new file mode 100644 index 00000000000000..4552257fb6c722 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-identical-prompts-remain-separate-completed-turns-after-resume.yaml @@ -0,0 +1,42 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "REPEATED_READY". Do not use tools. + response: + content: REPEATED_READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "REPEATED_READY". Do not use tools. + - role: assistant + content: REPEATED_READY + - role: user + content: Reply exactly "REPEATED_READY". Do not use tools. + response: + content: REPEATED_READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "REPEATED_READY". Do not use tools. + - role: assistant + content: REPEATED_READY + - role: user + content: Reply exactly "REPEATED_READY". Do not use tools. + - role: assistant + content: REPEATED_READY + - role: user + content: Reply exactly "CONTINUED". Do not use tools. + response: + content: CONTINUED + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-live-client-tool-replacement-reaches-both-materialized-chats.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-live-client-tool-replacement-reaches-both-materialized-chats.yaml new file mode 100644 index 00000000000000..7c85ddd1344a6a --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-live-client-tool-replacement-reaches-both-materialized-chats.yaml @@ -0,0 +1,228 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + response: + content: READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + - role: assistant + content: READY + - role: user + content: Call shared_probe exactly once with oldLabel set to default. + response: + content: + - type: tool_use + id: toolcall_0 + name: shared_probe + input: + oldLabel: default + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + - role: assistant + content: READY + - role: user + content: Call shared_probe exactly once with oldLabel set to default. + - role: assistant + content: + - type: thinking + - type: tool_use + name: shared_probe + input: + oldLabel: default + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: LIVE_PROBE_RESULT + response: + content: LIVE_PROBE_RESULT + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call shared_probe exactly once with oldLabel set to peer. + response: + content: + - type: tool_use + id: toolcall_1 + name: shared_probe + input: + oldLabel: peer + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call shared_probe exactly once with oldLabel set to peer. + - role: assistant + content: + - type: tool_use + name: shared_probe + input: + oldLabel: peer + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: LIVE_PROBE_RESULT + response: + content: 'The probe returned: **LIVE_PROBE_RESULT**' + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + - role: assistant + content: READY + - role: user + content: Call shared_probe exactly once with oldLabel set to default. + - role: assistant + content: + - type: thinking + - type: tool_use + name: shared_probe + input: + oldLabel: default + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: LIVE_PROBE_RESULT + - role: assistant + content: LIVE_PROBE_RESULT + - role: user + content: Call shared_probe exactly once with newLabel set to default. Use its current schema. + response: + content: + - type: tool_use + id: toolcall_2 + name: shared_probe + input: + newLabel: default + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + - role: assistant + content: READY + - role: user + content: Call shared_probe exactly once with oldLabel set to default. + - role: assistant + content: + - type: thinking + - type: tool_use + name: shared_probe + input: + oldLabel: default + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: LIVE_PROBE_RESULT + - role: assistant + content: LIVE_PROBE_RESULT + - role: user + content: Call shared_probe exactly once with newLabel set to default. Use its current schema. + - role: assistant + content: + - type: tool_use + name: shared_probe + input: + newLabel: default + - role: user + content: + - type: tool_result + tool_use_id: toolcall_2 + content: LIVE_PROBE_RESULT + response: + content: LIVE_PROBE_RESULT + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call shared_probe exactly once with oldLabel set to peer. + - role: assistant + content: + - type: tool_use + name: shared_probe + input: + oldLabel: peer + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: LIVE_PROBE_RESULT + - role: assistant + content: 'The probe returned: **LIVE_PROBE_RESULT**' + - role: user + content: Call shared_probe exactly once with newLabel set to peer. Use its current schema. + response: + content: + - type: tool_use + id: toolcall_3 + name: shared_probe + input: + newLabel: peer + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call shared_probe exactly once with oldLabel set to peer. + - role: assistant + content: + - type: tool_use + name: shared_probe + input: + oldLabel: peer + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: LIVE_PROBE_RESULT + - role: assistant + content: 'The probe returned: **LIVE_PROBE_RESULT**' + - role: user + content: Call shared_probe exactly once with newLabel set to peer. Use its current schema. + - role: assistant + content: + - type: thinking + - type: tool_use + name: shared_probe + input: + newLabel: peer + - role: user + content: + - type: tool_result + tool_use_id: toolcall_3 + content: LIVE_PROBE_RESULT + response: + content: 'The probe returned: **LIVE_PROBE_RESULT**' + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-peer-tool-history-and-subsequent-edits-stay-on-the-restored-peer.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-peer-tool-history-and-subsequent-edits-stay-on-the-restored-peer.yaml new file mode 100644 index 00000000000000..1d61ad7d336389 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-peer-tool-history-and-subsequent-edits-stay-on-the-restored-peer.yaml @@ -0,0 +1,122 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember DEFAULT_HISTORY_ONLY. Reply exactly "DEFAULT_READY". + response: + content: DEFAULT_READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use your file editing tool exactly once to replace PEER_BEFORE with PEER_MIDDLE in peer-edit.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + response: + content: + - type: tool_use + id: toolcall_0 + name: edit + input: + path: ${workdir}/peer-edit.txt + old_str: PEER_BEFORE + new_str: PEER_MIDDLE + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use your file editing tool exactly once to replace PEER_BEFORE with PEER_MIDDLE in peer-edit.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: + - type: tool_use + name: edit + input: + path: ${workdir}/peer-edit.txt + old_str: PEER_BEFORE + new_str: PEER_MIDDLE + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: File ${workdir}/peer-edit.txt updated with changes. + response: + content: EDIT_DONE + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use your file editing tool exactly once to replace PEER_BEFORE with PEER_MIDDLE in peer-edit.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: + - type: tool_use + name: edit + input: + path: ${workdir}/peer-edit.txt + old_str: PEER_BEFORE + new_str: PEER_MIDDLE + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: File ${workdir}/peer-edit.txt updated with changes. + - role: assistant + content: EDIT_DONE + - role: user + content: Use your file editing tool exactly once to replace PEER_MIDDLE with PEER_AFTER in peer-edit.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + response: + content: + - type: tool_use + id: toolcall_1 + name: edit + input: + path: ${workdir}/peer-edit.txt + old_str: PEER_MIDDLE + new_str: PEER_AFTER + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use your file editing tool exactly once to replace PEER_BEFORE with PEER_MIDDLE in peer-edit.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: + - type: tool_use + name: edit + input: + path: ${workdir}/peer-edit.txt + old_str: PEER_BEFORE + new_str: PEER_MIDDLE + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: File ${workdir}/peer-edit.txt updated with changes. + - role: assistant + content: EDIT_DONE + - role: user + content: Use your file editing tool exactly once to replace PEER_MIDDLE with PEER_AFTER in peer-edit.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: + - type: tool_use + name: edit + input: + path: ${workdir}/peer-edit.txt + old_str: PEER_MIDDLE + new_str: PEER_AFTER + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: File ${workdir}/peer-edit.txt updated with changes. + response: + content: EDIT_DONE + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-removing-an-obsolete-client-tool-leaves-its-replacement-executable.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-removing-an-obsolete-client-tool-leaves-its-replacement-executable.yaml new file mode 100644 index 00000000000000..2e445cbb93d627 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-removing-an-obsolete-client-tool-leaves-its-replacement-executable.yaml @@ -0,0 +1,123 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + response: + content: READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + - role: assistant + content: READY + - role: user + content: Call obsolete_probe exactly once and report its result. + response: + content: + - type: tool_use + id: toolcall_0 + name: obsolete_probe + input: {} + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + - role: assistant + content: READY + - role: user + content: Call obsolete_probe exactly once and report its result. + - role: assistant + content: + - type: thinking + - type: tool_use + name: obsolete_probe + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: LIVE_PROBE_RESULT + response: + content: 'The result of calling `obsolete_probe` is: **LIVE_PROBE_RESULT**' + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + - role: assistant + content: READY + - role: user + content: Call obsolete_probe exactly once and report its result. + - role: assistant + content: + - type: thinking + - type: tool_use + name: obsolete_probe + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: LIVE_PROBE_RESULT + - role: assistant + content: 'The result of calling `obsolete_probe` is: **LIVE_PROBE_RESULT**' + - role: user + content: Call replacement_probe exactly once. The obsolete_probe tool has been removed; do not call it. + response: + content: + - type: tool_use + id: toolcall_1 + name: replacement_probe + input: {} + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "READY". Do not call tools. + - role: assistant + content: READY + - role: user + content: Call obsolete_probe exactly once and report its result. + - role: assistant + content: + - type: thinking + - type: tool_use + name: obsolete_probe + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: LIVE_PROBE_RESULT + - role: assistant + content: 'The result of calling `obsolete_probe` is: **LIVE_PROBE_RESULT**' + - role: user + content: Call replacement_probe exactly once. The obsolete_probe tool has been removed; do not call it. + - role: assistant + content: + - type: tool_use + name: replacement_probe + input: {} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: LIVE_PROBE_RESULT + response: + content: 'The result of calling `replacement_probe` is: **LIVE_PROBE_RESULT**' + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-successive-file-edits-retain-their-turn-ownership-after-restart.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-successive-file-edits-retain-their-turn-ownership-after-restart.yaml new file mode 100644 index 00000000000000..b9e4a82e080e74 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-regression-coverage-successive-file-edits-retain-their-turn-ownership-after-restart.yaml @@ -0,0 +1,160 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use your file editing tool exactly once to replace EDIT_ZERO with EDIT_ONE in successive.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + response: + content: + - type: tool_use + id: toolcall_0 + name: edit + input: + path: ${workdir}/successive.txt + old_str: EDIT_ZERO + new_str: EDIT_ONE + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use your file editing tool exactly once to replace EDIT_ZERO with EDIT_ONE in successive.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: + - type: thinking + - type: tool_use + name: edit + input: + path: ${workdir}/successive.txt + old_str: EDIT_ZERO + new_str: EDIT_ONE + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: File ${workdir}/successive.txt updated with changes. + response: + content: EDIT_DONE + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use your file editing tool exactly once to replace EDIT_ZERO with EDIT_ONE in successive.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: + - type: thinking + - type: tool_use + name: edit + input: + path: ${workdir}/successive.txt + old_str: EDIT_ZERO + new_str: EDIT_ONE + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: File ${workdir}/successive.txt updated with changes. + - role: assistant + content: EDIT_DONE + - role: user + content: Use your file editing tool exactly once to replace EDIT_ONE with EDIT_TWO in successive.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + response: + content: + - type: tool_use + id: toolcall_1 + name: edit + input: + path: ${workdir}/successive.txt + old_str: EDIT_ONE + new_str: EDIT_TWO + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use your file editing tool exactly once to replace EDIT_ZERO with EDIT_ONE in successive.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: + - type: thinking + - type: tool_use + name: edit + input: + path: ${workdir}/successive.txt + old_str: EDIT_ZERO + new_str: EDIT_ONE + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: File ${workdir}/successive.txt updated with changes. + - role: assistant + content: EDIT_DONE + - role: user + content: Use your file editing tool exactly once to replace EDIT_ONE with EDIT_TWO in successive.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: + - type: tool_use + name: edit + input: + path: ${workdir}/successive.txt + old_str: EDIT_ONE + new_str: EDIT_TWO + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: File ${workdir}/successive.txt updated with changes. + response: + content: EDIT_DONE + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use your file editing tool exactly once to replace EDIT_ZERO with EDIT_ONE in successive.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: + - type: thinking + - type: tool_use + name: edit + input: + path: ${workdir}/successive.txt + old_str: EDIT_ZERO + new_str: EDIT_ONE + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: File ${workdir}/successive.txt updated with changes. + - role: assistant + content: EDIT_DONE + - role: user + content: Use your file editing tool exactly once to replace EDIT_ONE with EDIT_TWO in successive.txt. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE". + - role: assistant + content: + - type: tool_use + name: edit + input: + path: ${workdir}/successive.txt + old_str: EDIT_ONE + new_str: EDIT_TWO + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: File ${workdir}/successive.txt updated with changes. + - role: assistant + content: EDIT_DONE + - role: user + content: Reply exactly "TWO_EDITS_READY". Do not use tools. + response: + content: TWO_EDITS_READY + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-removing-managed-asks-refreshes-both-default-and-peer-chats-before-their-next-turn.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-removing-managed-asks-refreshes-both-default-and-peer-chats-before-their-next-turn.yaml new file mode 100644 index 00000000000000..635a8819eb7620 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-removing-managed-asks-refreshes-both-default-and-peer-chats-before-their-next-turn.yaml @@ -0,0 +1,214 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use create exactly once to create managed-default-before.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + response: + content: + - type: tool_use + id: toolcall_0 + name: create + input: + path: ${workdir}/managed-default-before.txt + file_text: MANAGED_WRITE_CONTENT + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use create exactly once to create managed-default-before.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + - role: assistant + content: + - type: tool_use + name: create + input: + path: ${workdir}/managed-default-before.txt + file_text: MANAGED_WRITE_CONTENT + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: Created file ${workdir}/managed-default-before.txt with 21 characters + response: + content: Created managed-default-before.txt with the content MANAGED_WRITE_CONTENT. + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use create exactly once to create managed-peer-before.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + response: + content: + - type: tool_use + id: toolcall_1 + name: create + input: + path: ${workdir}/managed-peer-before.txt + file_text: MANAGED_WRITE_CONTENT + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use create exactly once to create managed-peer-before.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + - role: assistant + content: + - type: thinking + - type: tool_use + name: create + input: + path: ${workdir}/managed-peer-before.txt + file_text: MANAGED_WRITE_CONTENT + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: Created file ${workdir}/managed-peer-before.txt with 21 characters + response: + content: Done — created managed-peer-before.txt with the exact content. + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use create exactly once to create managed-default-before.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + - role: assistant + content: + - type: tool_use + name: create + input: + path: ${workdir}/managed-default-before.txt + file_text: MANAGED_WRITE_CONTENT + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: Created file ${workdir}/managed-default-before.txt with 21 characters + - role: assistant + content: Created managed-default-before.txt with the content MANAGED_WRITE_CONTENT. + - role: user + content: Use create exactly once to create managed-default-after.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + response: + content: + - type: tool_use + id: toolcall_2 + name: create + input: + file_text: MANAGED_WRITE_CONTENT + path: ${workdir}/managed-default-after.txt + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use create exactly once to create managed-default-before.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + - role: assistant + content: + - type: tool_use + name: create + input: + path: ${workdir}/managed-default-before.txt + file_text: MANAGED_WRITE_CONTENT + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: Created file ${workdir}/managed-default-before.txt with 21 characters + - role: assistant + content: Created managed-default-before.txt with the content MANAGED_WRITE_CONTENT. + - role: user + content: Use create exactly once to create managed-default-after.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + - role: assistant + content: + - type: tool_use + name: create + input: + file_text: MANAGED_WRITE_CONTENT + path: ${workdir}/managed-default-after.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_2 + content: Created file ${workdir}/managed-default-after.txt with 21 characters + response: + content: Created managed-default-after.txt with the content MANAGED_WRITE_CONTENT. + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use create exactly once to create managed-peer-before.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + - role: assistant + content: + - type: thinking + - type: tool_use + name: create + input: + path: ${workdir}/managed-peer-before.txt + file_text: MANAGED_WRITE_CONTENT + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: Created file ${workdir}/managed-peer-before.txt with 21 characters + - role: assistant + content: Done — created managed-peer-before.txt with the exact content. + - role: user + content: Use create exactly once to create managed-peer-after.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + response: + content: + - type: tool_use + id: toolcall_3 + name: create + input: + path: ${workdir}/managed-peer-after.txt + file_text: MANAGED_WRITE_CONTENT + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Use create exactly once to create managed-peer-before.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + - role: assistant + content: + - type: thinking + - type: tool_use + name: create + input: + path: ${workdir}/managed-peer-before.txt + file_text: MANAGED_WRITE_CONTENT + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: Created file ${workdir}/managed-peer-before.txt with 21 characters + - role: assistant + content: Done — created managed-peer-before.txt with the exact content. + - role: user + content: Use create exactly once to create managed-peer-after.txt containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool. + - role: assistant + content: + - type: tool_use + name: create + input: + path: ${workdir}/managed-peer-after.txt + file_text: MANAGED_WRITE_CONTENT + - role: user + content: + - type: tool_result + tool_use_id: toolcall_3 + content: Created file ${workdir}/managed-peer-after.txt with 21 characters + response: + content: Done — created managed-peer-after.txt with the exact content. + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-removing-managed-telemetry-does-not-retain-the-previous-exporter-in-a-new-session.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-removing-managed-telemetry-does-not-retain-the-previous-exporter-in-a-new-session.yaml new file mode 100644 index 00000000000000..0080886fa6ffeb --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-removing-managed-telemetry-does-not-retain-the-previous-exporter-in-a-new-session.yaml @@ -0,0 +1,21 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "otel-before-removal". + response: + content: otel-before-removal + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "otel-after-removal". + response: + content: otel-after-removal + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/coverage/protocol-surface.json b/src/vs/platform/agentHost/test/node/e2e/coverage/protocol-surface.json index 29bb6e75dcdbc4..c3a6c8cca03450 100644 --- a/src/vs/platform/agentHost/test/node/e2e/coverage/protocol-surface.json +++ b/src/vs/platform/agentHost/test/node/e2e/coverage/protocol-surface.json @@ -6,9 +6,11 @@ }, "commands": { "covered": 32, - "total": 32, - "percentage": 100, - "uncovered": [] + "total": 33, + "percentage": 96.96, + "uncovered": [ + "moveChat" + ] }, "notifications": { "covered": 5, @@ -21,19 +23,24 @@ ] }, "actions": { - "covered": 88, - "total": 98, - "percentage": 89.79, + "covered": 91, + "total": 106, + "percentage": 85.84, "uncovered": [ "automationRun/sessionRemoved", + "canvas/stateChanged", "changeset/fileRemoved", "changeset/fileSet", + "chat/canvasesChanged", "chat/isArchivedChanged", + "chat/movableChanged", "chat/reasoning", "chat/toolCallAuthRequired", "chat/toolCallAuthResolved", "session/activityChanged", + "session/chatsReordered", "session/defaultChatChanged", + "session/mcpServerBackgroundRequested", "terminal/commandDetectionAvailable" ] } diff --git a/src/vs/platform/agentHost/test/node/e2e/coverage/summary.json b/src/vs/platform/agentHost/test/node/e2e/coverage/summary.json index 48787d541aa8e8..10faab360e9a2f 100644 --- a/src/vs/platform/agentHost/test/node/e2e/coverage/summary.json +++ b/src/vs/platform/agentHost/test/node/e2e/coverage/summary.json @@ -15,32 +15,32 @@ }, "total": { "statements": { - "covered": 119113, - "total": 160837, - "percentage": 74.05 + "covered": 129027, + "total": 175712, + "percentage": 73.43 }, "branches": { - "covered": 16635, - "total": 23886, - "percentage": 69.64 + "covered": 18961, + "total": 27020, + "percentage": 70.17 }, "functions": { - "covered": 4672, - "total": 6739, - "percentage": 69.32 + "covered": 5088, + "total": 7409, + "percentage": 68.67 }, "lines": { - "covered": 119113, - "total": 160837, - "percentage": 74.05 + "covered": 129027, + "total": 175712, + "percentage": 73.43 } }, "files": { "src/vs/platform/agentHost/common/agent.ts": { "statements": { - "covered": 1395, - "total": 1419, - "percentage": 98.3 + "covered": 1480, + "total": 1504, + "percentage": 98.4 }, "branches": { "covered": 27, @@ -53,9 +53,9 @@ "percentage": 62.5 }, "lines": { - "covered": 1395, - "total": 1419, - "percentage": 98.3 + "covered": 1480, + "total": 1504, + "percentage": 98.4 } }, "src/vs/platform/agentHost/common/agentClientUri.ts": { @@ -148,23 +148,23 @@ }, "src/vs/platform/agentHost/common/agentHostChangesetService.ts": { "statements": { - "covered": 297, - "total": 297, + "covered": 314, + "total": 314, "percentage": 100 }, "branches": { - "covered": 1, - "total": 1, + "covered": 2, + "total": 2, "percentage": 100 }, "functions": { - "covered": 1, - "total": 1, + "covered": 2, + "total": 2, "percentage": 100 }, "lines": { - "covered": 297, - "total": 297, + "covered": 314, + "total": 314, "percentage": 100 } }, @@ -192,30 +192,30 @@ }, "src/vs/platform/agentHost/common/agentHostChatContributionsService.ts": { "statements": { - "covered": 324, - "total": 326, - "percentage": 99.38 + "covered": 338, + "total": 338, + "percentage": 100 }, "branches": { - "covered": 1, - "total": 1, + "covered": 2, + "total": 2, "percentage": 100 }, "functions": { - "covered": 1, + "covered": 2, "total": 2, - "percentage": 50 + "percentage": 100 }, "lines": { - "covered": 324, - "total": 326, - "percentage": 99.38 + "covered": 338, + "total": 338, + "percentage": 100 } }, "src/vs/platform/agentHost/common/agentHostCheckpointService.ts": { "statements": { - "covered": 153, - "total": 153, + "covered": 156, + "total": 156, "percentage": 100 }, "branches": { @@ -229,8 +229,8 @@ "percentage": 11.11 }, "lines": { - "covered": 153, - "total": 153, + "covered": 156, + "total": 156, "percentage": 100 } }, @@ -281,8 +281,8 @@ "src/vs/platform/agentHost/common/agentHostConfigurationSync.ts": { "statements": { "covered": 88, - "total": 190, - "percentage": 46.31 + "total": 189, + "percentage": 46.56 }, "branches": { "covered": 1, @@ -296,30 +296,52 @@ }, "lines": { "covered": 88, - "total": 190, - "percentage": 46.31 + "total": 189, + "percentage": 46.56 + } + }, + "src/vs/platform/agentHost/common/agentHostConnectionsService.ts": { + "statements": { + "covered": 164, + "total": 164, + "percentage": 100 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "lines": { + "covered": 164, + "total": 164, + "percentage": 100 } }, "src/vs/platform/agentHost/common/agentHostConversationContext.ts": { "statements": { - "covered": 85, + "covered": 71, "total": 109, - "percentage": 77.98 + "percentage": 65.13 }, "branches": { - "covered": 4, - "total": 10, - "percentage": 40 + "covered": 3, + "total": 3, + "percentage": 100 }, "functions": { - "covered": 2, + "covered": 1, "total": 4, - "percentage": 50 + "percentage": 25 }, "lines": { - "covered": 85, + "covered": 71, "total": 109, - "percentage": 77.98 + "percentage": 65.13 } }, "src/vs/platform/agentHost/common/agentHostCustomizationConfig.ts": { @@ -390,14 +412,14 @@ }, "src/vs/platform/agentHost/common/agentHostExtensionProtocol.ts": { "statements": { - "covered": 164, - "total": 170, - "percentage": 96.47 + "covered": 197, + "total": 203, + "percentage": 97.04 }, "branches": { - "covered": 1, - "total": 4, - "percentage": 25 + "covered": 5, + "total": 8, + "percentage": 62.5 }, "functions": { "covered": 1, @@ -405,16 +427,16 @@ "percentage": 33.33 }, "lines": { - "covered": 164, - "total": 170, - "percentage": 96.47 + "covered": 197, + "total": 203, + "percentage": 97.04 } }, "src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts": { "statements": { - "covered": 462, - "total": 696, - "percentage": 66.37 + "covered": 464, + "total": 711, + "percentage": 65.26 }, "branches": { "covered": 46, @@ -427,9 +449,9 @@ "percentage": 71.42 }, "lines": { - "covered": 462, - "total": 696, - "percentage": 66.37 + "covered": 464, + "total": 711, + "percentage": 65.26 } }, "src/vs/platform/agentHost/common/agentHostFileSystemService.ts": { @@ -456,14 +478,14 @@ }, "src/vs/platform/agentHost/common/agentHostGitService.ts": { "statements": { - "covered": 478, - "total": 491, - "percentage": 97.35 + "covered": 485, + "total": 498, + "percentage": 97.38 }, "branches": { - "covered": 26, - "total": 29, - "percentage": 89.65 + "covered": 22, + "total": 24, + "percentage": 91.66 }, "functions": { "covered": 7, @@ -471,15 +493,15 @@ "percentage": 87.5 }, "lines": { - "covered": 478, - "total": 491, - "percentage": 97.35 + "covered": 485, + "total": 498, + "percentage": 97.38 } }, "src/vs/platform/agentHost/common/agentHostGitStateService.ts": { "statements": { - "covered": 84, - "total": 84, + "covered": 103, + "total": 103, "percentage": 100 }, "branches": { @@ -493,8 +515,8 @@ "percentage": 100 }, "lines": { - "covered": 84, - "total": 84, + "covered": 103, + "total": 103, "percentage": 100 } }, @@ -522,24 +544,24 @@ }, "src/vs/platform/agentHost/common/agentHostManagedSettings.ts": { "statements": { - "covered": 209, - "total": 341, - "percentage": 61.29 + "covered": 198, + "total": 316, + "percentage": 62.65 }, "branches": { - "covered": 4, - "total": 4, - "percentage": 100 + "covered": 10, + "total": 13, + "percentage": 76.92 }, "functions": { - "covered": 2, + "covered": 4, "total": 11, - "percentage": 18.18 + "percentage": 36.36 }, "lines": { - "covered": 209, - "total": 341, - "percentage": 61.29 + "covered": 198, + "total": 316, + "percentage": 62.65 } }, "src/vs/platform/agentHost/common/agentHostResourceService.ts": { @@ -588,9 +610,9 @@ }, "src/vs/platform/agentHost/common/agentHostSchema.ts": { "statements": { - "covered": 916, - "total": 1002, - "percentage": 91.41 + "covered": 965, + "total": 1051, + "percentage": 91.81 }, "branches": { "covered": 55, @@ -603,9 +625,31 @@ "percentage": 72.72 }, "lines": { - "covered": 916, - "total": 1002, - "percentage": 91.41 + "covered": 965, + "total": 1051, + "percentage": 91.81 + } + }, + "src/vs/platform/agentHost/common/agentHostSessionType.ts": { + "statements": { + "covered": 63, + "total": 114, + "percentage": 55.26 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 10, + "percentage": 0 + }, + "lines": { + "covered": 63, + "total": 114, + "percentage": 55.26 } }, "src/vs/platform/agentHost/common/agentHostSlashCommand.ts": { @@ -632,14 +676,14 @@ }, "src/vs/platform/agentHost/common/agentHostSubscriptionService.ts": { "statements": { - "covered": 42, - "total": 42, + "covered": 45, + "total": 45, "percentage": 100 }, "branches": { - "covered": 12, - "total": 12, - "percentage": 100 + "covered": 13, + "total": 14, + "percentage": 92.85 }, "functions": { "covered": 1, @@ -647,16 +691,16 @@ "percentage": 100 }, "lines": { - "covered": 42, - "total": 42, + "covered": 45, + "total": 45, "percentage": 100 } }, "src/vs/platform/agentHost/common/agentHostTelemetry.ts": { "statements": { - "covered": 159, - "total": 190, - "percentage": 83.68 + "covered": 205, + "total": 236, + "percentage": 86.86 }, "branches": { "covered": 10, @@ -669,9 +713,9 @@ "percentage": 77.77 }, "lines": { - "covered": 159, - "total": 190, - "percentage": 83.68 + "covered": 205, + "total": 236, + "percentage": 86.86 } }, "src/vs/platform/agentHost/common/agentHostTelemetryEnv.ts": { @@ -742,31 +786,31 @@ }, "src/vs/platform/agentHost/common/agentMerge.ts": { "statements": { - "covered": 538, - "total": 1162, - "percentage": 46.29 + "covered": 511, + "total": 1172, + "percentage": 43.6 }, "branches": { - "covered": 20, - "total": 87, - "percentage": 22.98 + "covered": 17, + "total": 55, + "percentage": 30.9 }, "functions": { - "covered": 10, - "total": 60, - "percentage": 16.66 + "covered": 9, + "total": 61, + "percentage": 14.75 }, "lines": { - "covered": 538, - "total": 1162, - "percentage": 46.29 + "covered": 511, + "total": 1172, + "percentage": 43.6 } }, "src/vs/platform/agentHost/common/agentMergePrompt.ts": { "statements": { - "covered": 113, - "total": 336, - "percentage": 33.63 + "covered": 115, + "total": 338, + "percentage": 34.02 }, "branches": { "covered": 0, @@ -779,9 +823,9 @@ "percentage": 0 }, "lines": { - "covered": 113, - "total": 336, - "percentage": 33.63 + "covered": 115, + "total": 338, + "percentage": 34.02 } }, "src/vs/platform/agentHost/common/agentModelByokMeta.ts": { @@ -850,28 +894,6 @@ "percentage": 59.37 } }, - "src/vs/platform/agentHost/common/agentModelPricing.ts": { - "statements": { - "covered": 200, - "total": 303, - "percentage": 66 - }, - "branches": { - "covered": 5, - "total": 40, - "percentage": 12.5 - }, - "functions": { - "covered": 4, - "total": 8, - "percentage": 50 - }, - "lines": { - "covered": 200, - "total": 303, - "percentage": 66 - } - }, "src/vs/platform/agentHost/common/agentModelSource.ts": { "statements": { "covered": 41, @@ -896,9 +918,9 @@ }, "src/vs/platform/agentHost/common/agentPluginManager.ts": { "statements": { - "covered": 55, - "total": 55, - "percentage": 100 + "covered": 73, + "total": 75, + "percentage": 97.33 }, "branches": { "covered": 0, @@ -907,13 +929,13 @@ }, "functions": { "covered": 0, - "total": 0, - "percentage": 100 + "total": 1, + "percentage": 0 }, "lines": { - "covered": 55, - "total": 55, - "percentage": 100 + "covered": 73, + "total": 75, + "percentage": 97.33 } }, "src/vs/platform/agentHost/common/agentSdkSetup.ts": { @@ -940,9 +962,9 @@ }, "src/vs/platform/agentHost/common/agentService.ts": { "statements": { - "covered": 1089, - "total": 1254, - "percentage": 86.84 + "covered": 1138, + "total": 1332, + "percentage": 85.43 }, "branches": { "covered": 9, @@ -951,19 +973,41 @@ }, "functions": { "covered": 1, - "total": 9, - "percentage": 11.11 + "total": 12, + "percentage": 8.33 }, "lines": { - "covered": 1089, - "total": 1254, - "percentage": 86.84 + "covered": 1138, + "total": 1332, + "percentage": 85.43 + } + }, + "src/vs/platform/agentHost/common/agentSessionMessage.ts": { + "statements": { + "covered": 24, + "total": 49, + "percentage": 48.97 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 3, + "percentage": 0 + }, + "lines": { + "covered": 24, + "total": 49, + "percentage": 48.97 } }, "src/vs/platform/agentHost/common/agentTelemetryCorrelation.ts": { "statements": { - "covered": 12, - "total": 12, + "covered": 13, + "total": 13, "percentage": 100 }, "branches": { @@ -977,31 +1021,31 @@ "percentage": 100 }, "lines": { - "covered": 12, - "total": 12, + "covered": 13, + "total": 13, "percentage": 100 } }, "src/vs/platform/agentHost/common/ahpJsonlLogger.ts": { "statements": { - "covered": 238, - "total": 277, - "percentage": 85.92 + "covered": 235, + "total": 293, + "percentage": 80.2 }, "branches": { - "covered": 36, - "total": 45, - "percentage": 80 + "covered": 35, + "total": 52, + "percentage": 67.3 }, "functions": { "covered": 13, - "total": 18, - "percentage": 72.22 + "total": 19, + "percentage": 68.42 }, "lines": { - "covered": 238, - "total": 277, - "percentage": 85.92 + "covered": 235, + "total": 293, + "percentage": 80.2 } }, "src/vs/platform/agentHost/common/annotationsUri.ts": { @@ -1070,29 +1114,51 @@ "percentage": 69.13 } }, - "src/vs/platform/agentHost/common/changesetUri.ts": { + "src/vs/platform/agentHost/common/canvasUri.ts": { "statements": { - "covered": 407, - "total": 509, - "percentage": 79.96 + "covered": 23, + "total": 35, + "percentage": 65.71 }, "branches": { - "covered": 80, - "total": 99, - "percentage": 80.8 + "covered": 1, + "total": 9, + "percentage": 11.11 }, "functions": { - "covered": 22, - "total": 33, - "percentage": 66.66 + "covered": 1, + "total": 2, + "percentage": 50 }, "lines": { - "covered": 407, - "total": 509, - "percentage": 79.96 + "covered": 23, + "total": 35, + "percentage": 65.71 } }, - "src/vs/platform/agentHost/common/claudeModelConfig.ts": { + "src/vs/platform/agentHost/common/changesetUri.ts": { + "statements": { + "covered": 431, + "total": 534, + "percentage": 80.71 + }, + "branches": { + "covered": 89, + "total": 110, + "percentage": 80.9 + }, + "functions": { + "covered": 22, + "total": 33, + "percentage": 66.66 + }, + "lines": { + "covered": 431, + "total": 534, + "percentage": 80.71 + } + }, + "src/vs/platform/agentHost/common/claudeModelConfig.ts": { "statements": { "covered": 123, "total": 126, @@ -1226,9 +1292,9 @@ }, "src/vs/platform/agentHost/common/copilotCliConfig.ts": { "statements": { - "covered": 282, - "total": 291, - "percentage": 96.9 + "covered": 312, + "total": 321, + "percentage": 97.19 }, "branches": { "covered": 8, @@ -1241,9 +1307,9 @@ "percentage": 75 }, "lines": { - "covered": 282, - "total": 291, - "percentage": 96.9 + "covered": 312, + "total": 321, + "percentage": 97.19 } }, "src/vs/platform/agentHost/common/copilotConfigSlashCommands.ts": { @@ -1268,28 +1334,6 @@ "percentage": 74.32 } }, - "src/vs/platform/agentHost/common/copilotHome.ts": { - "statements": { - "covered": 13, - "total": 18, - "percentage": 72.22 - }, - "branches": { - "covered": 1, - "total": 2, - "percentage": 50 - }, - "functions": { - "covered": 1, - "total": 2, - "percentage": 50 - }, - "lines": { - "covered": 13, - "total": 18, - "percentage": 72.22 - } - }, "src/vs/platform/agentHost/common/customizationEnablement.ts": { "statements": { "covered": 76, @@ -1314,8 +1358,8 @@ }, "src/vs/platform/agentHost/common/devContainerAgentHost.ts": { "statements": { - "covered": 50, - "total": 50, + "covered": 59, + "total": 59, "percentage": 100 }, "branches": { @@ -1329,9 +1373,31 @@ "percentage": 100 }, "lines": { - "covered": 50, - "total": 50, + "covered": 59, + "total": 59, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/common/devContainerSamples.ts": { + "statements": { + "covered": 38, + "total": 48, + "percentage": 79.16 + }, + "branches": { + "covered": 0, + "total": 0, "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 4, + "percentage": 0 + }, + "lines": { + "covered": 38, + "total": 48, + "percentage": 79.16 } }, "src/vs/platform/agentHost/common/diffComputeService.ts": { @@ -1358,9 +1424,9 @@ }, "src/vs/platform/agentHost/common/fileEditAttribution.ts": { "statements": { - "covered": 157, - "total": 271, - "percentage": 57.93 + "covered": 160, + "total": 275, + "percentage": 58.18 }, "branches": { "covered": 1, @@ -1373,175 +1439,1209 @@ "percentage": 6.66 }, "lines": { - "covered": 157, - "total": 271, - "percentage": 57.93 + "covered": 160, + "total": 275, + "percentage": 58.18 } }, "src/vs/platform/agentHost/common/githubEndpoints.ts": { "statements": { - "covered": 120, - "total": 130, - "percentage": 92.3 + "covered": 32, + "total": 32, + "percentage": 100 }, "branches": { - "covered": 14, - "total": 23, - "percentage": 60.86 + "covered": 2, + "total": 2, + "percentage": 100 }, "functions": { - "covered": 4, - "total": 4, + "covered": 2, + "total": 2, "percentage": 100 }, "lines": { - "covered": 120, - "total": 130, - "percentage": 92.3 + "covered": 32, + "total": 32, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/common/meta/agentAuthenticationAccount.ts": { + "statements": { + "covered": 7, + "total": 7, + "percentage": 100 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "lines": { + "covered": 7, + "total": 7, + "percentage": 100 } }, "src/vs/platform/agentHost/common/meta/agentChatInputRequestMeta.ts": { "statements": { - "covered": 53, - "total": 53, + "covered": 6, + "total": 6, "percentage": 100 }, "branches": { - "covered": 8, - "total": 9, - "percentage": 88.88 + "covered": 0, + "total": 0, + "percentage": 100 }, "functions": { - "covered": 3, - "total": 3, + "covered": 0, + "total": 0, "percentage": 100 }, "lines": { - "covered": 53, - "total": 53, + "covered": 6, + "total": 6, "percentage": 100 } }, "src/vs/platform/agentHost/common/meta/agentChatSurfaceMeta.ts": { "statements": { - "covered": 69, - "total": 142, - "percentage": 48.59 + "covered": 7, + "total": 7, + "percentage": 100 }, "branches": { - "covered": 3, - "total": 11, - "percentage": 27.27 + "covered": 0, + "total": 0, + "percentage": 100 }, "functions": { - "covered": 2, - "total": 5, - "percentage": 40 + "covered": 0, + "total": 0, + "percentage": 100 }, "lines": { - "covered": 69, - "total": 142, - "percentage": 48.59 + "covered": 7, + "total": 7, + "percentage": 100 } }, "src/vs/platform/agentHost/common/meta/agentCheckoutOperationMeta.ts": { "statements": { - "covered": 50, - "total": 66, - "percentage": 75.75 + "covered": 6, + "total": 6, + "percentage": 100 }, "branches": { - "covered": 7, - "total": 10, - "percentage": 70 + "covered": 0, + "total": 0, + "percentage": 100 }, "functions": { - "covered": 3, - "total": 6, - "percentage": 50 + "covered": 0, + "total": 0, + "percentage": 100 }, "lines": { - "covered": 50, - "total": 66, - "percentage": 75.75 + "covered": 6, + "total": 6, + "percentage": 100 } }, "src/vs/platform/agentHost/common/meta/agentCompletionAttachmentMeta.ts": { "statements": { - "covered": 149, - "total": 225, - "percentage": 66.22 + "covered": 7, + "total": 7, + "percentage": 100 }, "branches": { - "covered": 2, - "total": 8, - "percentage": 25 + "covered": 0, + "total": 0, + "percentage": 100 }, "functions": { - "covered": 2, - "total": 7, - "percentage": 28.57 + "covered": 0, + "total": 0, + "percentage": 100 }, "lines": { - "covered": 149, - "total": 225, - "percentage": 66.22 + "covered": 7, + "total": 7, + "percentage": 100 } }, "src/vs/platform/agentHost/common/meta/agentCustomizationMeta.ts": { "statements": { - "covered": 44, - "total": 53, - "percentage": 83.01 + "covered": 7, + "total": 7, + "percentage": 100 }, "branches": { - "covered": 1, - "total": 2, - "percentage": 50 + "covered": 0, + "total": 0, + "percentage": 100 }, "functions": { - "covered": 1, - "total": 2, - "percentage": 50 + "covered": 0, + "total": 0, + "percentage": 100 }, "lines": { - "covered": 44, - "total": 53, - "percentage": 83.01 + "covered": 7, + "total": 7, + "percentage": 100 } }, "src/vs/platform/agentHost/common/meta/agentDevContainerWorktreeMeta.ts": { "statements": { - "covered": 25, - "total": 34, - "percentage": 73.52 + "covered": 7, + "total": 7, + "percentage": 100 }, "branches": { - "covered": 3, - "total": 7, - "percentage": 42.85 + "covered": 0, + "total": 0, + "percentage": 100 }, "functions": { - "covered": 2, - "total": 3, - "percentage": 66.66 + "covered": 0, + "total": 0, + "percentage": 100 }, "lines": { - "covered": 25, - "total": 34, - "percentage": 73.52 + "covered": 7, + "total": 7, + "percentage": 100 } }, "src/vs/platform/agentHost/common/meta/agentEphemeralSessionMeta.ts": { "statements": { - "covered": 30, - "total": 30, + "covered": 7, + "total": 7, "percentage": 100 }, "branches": { - "covered": 3, - "total": 5, - "percentage": 60 + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "lines": { + "covered": 7, + "total": 7, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/common/meta/agentFeedbackAnnotations.ts": { + "statements": { + "covered": 7, + "total": 7, + "percentage": 100 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "lines": { + "covered": 7, + "total": 7, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/common/meta/agentFeedbackAttachments.ts": { + "statements": { + "covered": 7, + "total": 7, + "percentage": 100 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "lines": { + "covered": 7, + "total": 7, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/common/meta/agentHostArtifactRemovalMeta.ts": { + "statements": { + "covered": 6, + "total": 6, + "percentage": 100 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "lines": { + "covered": 6, + "total": 6, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/common/meta/agentHostAutomationsMeta.ts": { + "statements": { + "covered": 6, + "total": 6, + "percentage": 100 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "lines": { + "covered": 6, + "total": 6, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/common/meta/agentHostChatInputState.ts": { + "statements": { + "covered": 7, + "total": 7, + "percentage": 100 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "lines": { + "covered": 7, + "total": 7, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/common/meta/agentHostDevContainersMeta.ts": { + "statements": { + "covered": 6, + "total": 6, + "percentage": 100 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "lines": { + "covered": 6, + "total": 6, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/common/meta/agentHostResources.ts": { + "statements": { + "covered": 7, + "total": 7, + "percentage": 100 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "lines": { + "covered": 7, + "total": 7, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/common/meta/agentHostSessionImportMeta.ts": { + "statements": { + "covered": 6, + "total": 6, + "percentage": 100 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "lines": { + "covered": 6, + "total": 6, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/common/meta/agentHostTimingMeta.ts": { + "statements": { + "covered": 6, + "total": 6, + "percentage": 100 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "lines": { + "covered": 6, + "total": 6, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/common/meta/agentMergeMessageMeta.ts": { + "statements": { + "covered": 6, + "total": 6, + "percentage": 100 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "lines": { + "covered": 6, + "total": 6, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/common/meta/agentMessageDelegationMeta.ts": { + "statements": { + "covered": 7, + "total": 7, + "percentage": 100 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "lines": { + "covered": 7, + "total": 7, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/common/meta/agentMessageMeta.ts": { + "statements": { + "covered": 28, + "total": 45, + "percentage": 62.22 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 6, + "percentage": 0 + }, + "lines": { + "covered": 28, + "total": 45, + "percentage": 62.22 + } + }, + "src/vs/platform/agentHost/common/meta/agentModelCallMeta.ts": { + "statements": { + "covered": 23, + "total": 36, + "percentage": 63.88 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 1, + "percentage": 0 + }, + "lines": { + "covered": 23, + "total": 36, + "percentage": 63.88 + } + }, + "src/vs/platform/agentHost/common/meta/agentModelConfigurationMeta.ts": { + "statements": { + "covered": 7, + "total": 7, + "percentage": 100 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "lines": { + "covered": 7, + "total": 7, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/common/meta/agentModelMeta.ts": { + "statements": { + "covered": 17, + "total": 23, + "percentage": 73.91 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 1, + "percentage": 0 + }, + "lines": { + "covered": 17, + "total": 23, + "percentage": 73.91 + } + }, + "src/vs/platform/agentHost/common/meta/agentPermissionRequestMeta.ts": { + "statements": { + "covered": 13, + "total": 15, + "percentage": 86.66 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 1, + "percentage": 0 + }, + "lines": { + "covered": 13, + "total": 15, + "percentage": 86.66 + } + }, + "src/vs/platform/agentHost/common/meta/agentPermissionResponseMeta.ts": { + "statements": { + "covered": 7, + "total": 7, + "percentage": 100 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "lines": { + "covered": 7, + "total": 7, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/common/meta/agentPullRequestOperationMeta.ts": { + "statements": { + "covered": 7, + "total": 7, + "percentage": 100 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "lines": { + "covered": 7, + "total": 7, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/common/meta/agentRemoteSessionMeta.ts": { + "statements": { + "covered": 7, + "total": 7, + "percentage": 100 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "lines": { + "covered": 7, + "total": 7, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/common/meta/agentSandboxDiagnostics.ts": { + "statements": { + "covered": 6, + "total": 6, + "percentage": 100 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "lines": { + "covered": 6, + "total": 6, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/common/meta/agentSandboxPolicyMeta.ts": { + "statements": { + "covered": 7, + "total": 7, + "percentage": 100 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "lines": { + "covered": 7, + "total": 7, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/common/meta/agentSandboxStateMeta.ts": { + "statements": { + "covered": 7, + "total": 7, + "percentage": 100 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "lines": { + "covered": 7, + "total": 7, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/common/meta/agentSessionInitiatorMeta.ts": { + "statements": { + "covered": 39, + "total": 43, + "percentage": 90.69 + }, + "branches": { + "covered": 14, + "total": 19, + "percentage": 73.68 + }, + "functions": { + "covered": 4, + "total": 4, + "percentage": 100 + }, + "lines": { + "covered": 39, + "total": 43, + "percentage": 90.69 + } + }, + "src/vs/platform/agentHost/common/meta/agentSlashCommandOutputMeta.ts": { + "statements": { + "covered": 7, + "total": 7, + "percentage": 100 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "lines": { + "covered": 7, + "total": 7, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/common/meta/agentSnapshotAttachmentMeta.ts": { + "statements": { + "covered": 7, + "total": 7, + "percentage": 100 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "lines": { + "covered": 7, + "total": 7, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/common/meta/agentSystemNotificationMeta.ts": { + "statements": { + "covered": 7, + "total": 7, + "percentage": 100 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "lines": { + "covered": 7, + "total": 7, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/common/meta/agentToolCallMeta.ts": { + "statements": { + "covered": 41, + "total": 63, + "percentage": 65.07 + }, + "branches": { + "covered": 1, + "total": 1, + "percentage": 100 + }, + "functions": { + "covered": 1, + "total": 4, + "percentage": 25 + }, + "lines": { + "covered": 41, + "total": 63, + "percentage": 65.07 + } + }, + "src/vs/platform/agentHost/common/meta/agentUsageMeta.ts": { + "statements": { + "covered": 53, + "total": 55, + "percentage": 96.36 + }, + "branches": { + "covered": 4, + "total": 5, + "percentage": 80 + }, + "functions": { + "covered": 1, + "total": 2, + "percentage": 50 + }, + "lines": { + "covered": 53, + "total": 55, + "percentage": 96.36 + } + }, + "src/vs/platform/agentHost/common/meta/agentWorkspaceContinuationMeta.ts": { + "statements": { + "covered": 6, + "total": 6, + "percentage": 100 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "lines": { + "covered": 6, + "total": 6, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/common/meta/clientPluginCustomizationMeta.ts": { + "statements": { + "covered": 7, + "total": 7, + "percentage": 100 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "lines": { + "covered": 7, + "total": 7, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/common/meta/codexAccount.ts": { + "statements": { + "covered": 7, + "total": 7, + "percentage": 100 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "lines": { + "covered": 7, + "total": 7, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/common/meta/copilotBackgroundWorkMeta.ts": { + "statements": { + "covered": 6, + "total": 6, + "percentage": 100 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "lines": { + "covered": 6, + "total": 6, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/common/meta/copilotd/agentPermissionRequestMeta.ts": { + "statements": { + "covered": 61, + "total": 94, + "percentage": 64.89 + }, + "branches": { + "covered": 1, + "total": 1, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 3, + "percentage": 0 + }, + "lines": { + "covered": 61, + "total": 94, + "percentage": 64.89 + } + }, + "src/vs/platform/agentHost/common/meta/copilotd/copilotdMetadataReader.ts": { + "statements": { + "covered": 113, + "total": 243, + "percentage": 46.5 + }, + "branches": { + "covered": 5, + "total": 22, + "percentage": 22.72 + }, + "functions": { + "covered": 4, + "total": 23, + "percentage": 17.39 + }, + "lines": { + "covered": 113, + "total": 243, + "percentage": 46.5 + } + }, + "src/vs/platform/agentHost/common/meta/copilotd/toolPresentation.ts": { + "statements": { + "covered": 134, + "total": 195, + "percentage": 68.71 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 3, + "percentage": 0 + }, + "lines": { + "covered": 134, + "total": 195, + "percentage": 68.71 + } + }, + "src/vs/platform/agentHost/common/meta/errorMeta.ts": { + "statements": { + "covered": 36, + "total": 43, + "percentage": 83.72 + }, + "branches": { + "covered": 6, + "total": 8, + "percentage": 75 + }, + "functions": { + "covered": 1, + "total": 2, + "percentage": 50 + }, + "lines": { + "covered": 36, + "total": 43, + "percentage": 83.72 + } + }, + "src/vs/platform/agentHost/common/meta/mcpCustomizationMeta.ts": { + "statements": { + "covered": 7, + "total": 7, + "percentage": 100 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "lines": { + "covered": 7, + "total": 7, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/common/meta/metadata.ts": { + "statements": { + "covered": 14, + "total": 14, + "percentage": 100 + }, + "branches": { + "covered": 3, + "total": 3, + "percentage": 100 + }, + "functions": { + "covered": 1, + "total": 1, + "percentage": 100 + }, + "lines": { + "covered": 14, + "total": 14, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/common/meta/vscode/agentAuthenticationAccount.ts": { + "statements": { + "covered": 32, + "total": 41, + "percentage": 78.04 + }, + "branches": { + "covered": 2, + "total": 10, + "percentage": 20 + }, + "functions": { + "covered": 2, + "total": 3, + "percentage": 66.66 + }, + "lines": { + "covered": 32, + "total": 41, + "percentage": 78.04 + } + }, + "src/vs/platform/agentHost/common/meta/vscode/agentChatInputRequestMeta.ts": { + "statements": { + "covered": 53, + "total": 53, + "percentage": 100 + }, + "branches": { + "covered": 8, + "total": 9, + "percentage": 88.88 + }, + "functions": { + "covered": 3, + "total": 3, + "percentage": 100 + }, + "lines": { + "covered": 53, + "total": 53, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/common/meta/vscode/agentChatSurfaceMeta.ts": { + "statements": { + "covered": 69, + "total": 142, + "percentage": 48.59 + }, + "branches": { + "covered": 3, + "total": 11, + "percentage": 27.27 + }, + "functions": { + "covered": 2, + "total": 5, + "percentage": 40 + }, + "lines": { + "covered": 69, + "total": 142, + "percentage": 48.59 + } + }, + "src/vs/platform/agentHost/common/meta/vscode/agentCheckoutOperationMeta.ts": { + "statements": { + "covered": 50, + "total": 66, + "percentage": 75.75 + }, + "branches": { + "covered": 7, + "total": 10, + "percentage": 70 + }, + "functions": { + "covered": 3, + "total": 6, + "percentage": 50 + }, + "lines": { + "covered": 50, + "total": 66, + "percentage": 75.75 + } + }, + "src/vs/platform/agentHost/common/meta/vscode/agentCompletionAttachmentMeta.ts": { + "statements": { + "covered": 155, + "total": 237, + "percentage": 65.4 + }, + "branches": { + "covered": 2, + "total": 10, + "percentage": 20 + }, + "functions": { + "covered": 2, + "total": 7, + "percentage": 28.57 + }, + "lines": { + "covered": 155, + "total": 237, + "percentage": 65.4 + } + }, + "src/vs/platform/agentHost/common/meta/vscode/agentCustomizationMeta.ts": { + "statements": { + "covered": 44, + "total": 53, + "percentage": 83.01 + }, + "branches": { + "covered": 1, + "total": 2, + "percentage": 50 + }, + "functions": { + "covered": 1, + "total": 2, + "percentage": 50 + }, + "lines": { + "covered": 44, + "total": 53, + "percentage": 83.01 + } + }, + "src/vs/platform/agentHost/common/meta/vscode/agentDevContainerWorktreeMeta.ts": { + "statements": { + "covered": 25, + "total": 34, + "percentage": 73.52 + }, + "branches": { + "covered": 3, + "total": 7, + "percentage": 42.85 + }, + "functions": { + "covered": 2, + "total": 3, + "percentage": 66.66 + }, + "lines": { + "covered": 25, + "total": 34, + "percentage": 73.52 + } + }, + "src/vs/platform/agentHost/common/meta/vscode/agentEphemeralSessionMeta.ts": { + "statements": { + "covered": 30, + "total": 30, + "percentage": 100 + }, + "branches": { + "covered": 3, + "total": 5, + "percentage": 60 }, "functions": { "covered": 2, @@ -1554,29 +2654,29 @@ "percentage": 100 } }, - "src/vs/platform/agentHost/common/meta/agentErrorMeta.ts": { + "src/vs/platform/agentHost/common/meta/vscode/agentErrorMeta.ts": { "statements": { - "covered": 26, - "total": 27, - "percentage": 96.29 + "covered": 29, + "total": 43, + "percentage": 67.44 }, "branches": { "covered": 13, - "total": 16, - "percentage": 81.25 + "total": 17, + "percentage": 76.47 }, "functions": { "covered": 1, - "total": 1, - "percentage": 100 + "total": 2, + "percentage": 50 }, "lines": { - "covered": 26, - "total": 27, - "percentage": 96.29 + "covered": 29, + "total": 43, + "percentage": 67.44 } }, - "src/vs/platform/agentHost/common/meta/agentFeedbackAnnotations.ts": { + "src/vs/platform/agentHost/common/meta/vscode/agentFeedbackAnnotations.ts": { "statements": { "covered": 199, "total": 212, @@ -1598,7 +2698,7 @@ "percentage": 93.86 } }, - "src/vs/platform/agentHost/common/meta/agentFeedbackAttachments.ts": { + "src/vs/platform/agentHost/common/meta/vscode/agentFeedbackAttachments.ts": { "statements": { "covered": 50, "total": 128, @@ -1620,7 +2720,7 @@ "percentage": 39.06 } }, - "src/vs/platform/agentHost/common/meta/agentHostArtifactRemovalMeta.ts": { + "src/vs/platform/agentHost/common/meta/vscode/agentHostArtifactRemovalMeta.ts": { "statements": { "covered": 11, "total": 13, @@ -1642,7 +2742,7 @@ "percentage": 84.61 } }, - "src/vs/platform/agentHost/common/meta/agentHostAutomationsMeta.ts": { + "src/vs/platform/agentHost/common/meta/vscode/agentHostAutomationsMeta.ts": { "statements": { "covered": 12, "total": 14, @@ -1664,7 +2764,29 @@ "percentage": 85.71 } }, - "src/vs/platform/agentHost/common/meta/agentHostDevContainersMeta.ts": { + "src/vs/platform/agentHost/common/meta/vscode/agentHostChatInputState.ts": { + "statements": { + "covered": 44, + "total": 47, + "percentage": 93.61 + }, + "branches": { + "covered": 19, + "total": 20, + "percentage": 95 + }, + "functions": { + "covered": 4, + "total": 4, + "percentage": 100 + }, + "lines": { + "covered": 44, + "total": 47, + "percentage": 93.61 + } + }, + "src/vs/platform/agentHost/common/meta/vscode/agentHostDevContainersMeta.ts": { "statements": { "covered": 11, "total": 13, @@ -1686,7 +2808,29 @@ "percentage": 84.61 } }, - "src/vs/platform/agentHost/common/meta/agentHostTimingMeta.ts": { + "src/vs/platform/agentHost/common/meta/vscode/agentHostResources.ts": { + "statements": { + "covered": 57, + "total": 65, + "percentage": 87.69 + }, + "branches": { + "covered": 4, + "total": 19, + "percentage": 21.05 + }, + "functions": { + "covered": 4, + "total": 5, + "percentage": 80 + }, + "lines": { + "covered": 57, + "total": 65, + "percentage": 87.69 + } + }, + "src/vs/platform/agentHost/common/meta/vscode/agentHostSessionImportMeta.ts": { "statements": { "covered": 11, "total": 13, @@ -1708,7 +2852,29 @@ "percentage": 84.61 } }, - "src/vs/platform/agentHost/common/meta/agentMergeMessageMeta.ts": { + "src/vs/platform/agentHost/common/meta/vscode/agentHostTimingMeta.ts": { + "statements": { + "covered": 14, + "total": 18, + "percentage": 77.77 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 2, + "percentage": 0 + }, + "lines": { + "covered": 14, + "total": 18, + "percentage": 77.77 + } + }, + "src/vs/platform/agentHost/common/meta/vscode/agentMergeMessageMeta.ts": { "statements": { "covered": 23, "total": 27, @@ -1730,29 +2896,51 @@ "percentage": 85.18 } }, - "src/vs/platform/agentHost/common/meta/agentMessageDelegationMeta.ts": { + "src/vs/platform/agentHost/common/meta/vscode/agentMessageDelegationMeta.ts": { "statements": { - "covered": 49, - "total": 53, - "percentage": 92.45 + "covered": 56, + "total": 67, + "percentage": 83.58 }, "branches": { - "covered": 14, - "total": 19, - "percentage": 73.68 + "covered": 16, + "total": 26, + "percentage": 61.53 }, "functions": { "covered": 3, - "total": 3, + "total": 4, + "percentage": 75 + }, + "lines": { + "covered": 56, + "total": 67, + "percentage": 83.58 + } + }, + "src/vs/platform/agentHost/common/meta/vscode/agentMessageMeta.ts": { + "statements": { + "covered": 22, + "total": 46, + "percentage": 47.82 + }, + "branches": { + "covered": 0, + "total": 0, "percentage": 100 }, + "functions": { + "covered": 0, + "total": 5, + "percentage": 0 + }, "lines": { - "covered": 49, - "total": 53, - "percentage": 92.45 + "covered": 22, + "total": 46, + "percentage": 47.82 } }, - "src/vs/platform/agentHost/common/meta/agentModelCallMeta.ts": { + "src/vs/platform/agentHost/common/meta/vscode/agentModelCallMeta.ts": { "statements": { "covered": 53, "total": 59, @@ -1774,51 +2962,205 @@ "percentage": 89.83 } }, - "src/vs/platform/agentHost/common/meta/agentPermissionRequestMeta.ts": { + "src/vs/platform/agentHost/common/meta/vscode/agentModelConfigurationMeta.ts": { "statements": { - "covered": 58, - "total": 82, - "percentage": 70.73 + "covered": 28, + "total": 30, + "percentage": 93.33 + }, + "branches": { + "covered": 11, + "total": 15, + "percentage": 73.33 + }, + "functions": { + "covered": 1, + "total": 1, + "percentage": 100 + }, + "lines": { + "covered": 28, + "total": 30, + "percentage": 93.33 + } + }, + "src/vs/platform/agentHost/common/meta/vscode/agentModelPricing.ts": { + "statements": { + "covered": 187, + "total": 288, + "percentage": 64.93 + }, + "branches": { + "covered": 5, + "total": 40, + "percentage": 12.5 + }, + "functions": { + "covered": 4, + "total": 8, + "percentage": 50 + }, + "lines": { + "covered": 187, + "total": 288, + "percentage": 64.93 + } + }, + "src/vs/platform/agentHost/common/meta/vscode/agentPermissionResponseMeta.ts": { + "statements": { + "covered": 28, + "total": 36, + "percentage": 77.77 + }, + "branches": { + "covered": 1, + "total": 6, + "percentage": 16.66 + }, + "functions": { + "covered": 1, + "total": 2, + "percentage": 50 + }, + "lines": { + "covered": 28, + "total": 36, + "percentage": 77.77 + } + }, + "src/vs/platform/agentHost/common/meta/vscode/agentPullRequestOperationMeta.ts": { + "statements": { + "covered": 144, + "total": 285, + "percentage": 50.52 + }, + "branches": { + "covered": 22, + "total": 35, + "percentage": 62.85 + }, + "functions": { + "covered": 7, + "total": 20, + "percentage": 35 + }, + "lines": { + "covered": 144, + "total": 285, + "percentage": 50.52 + } + }, + "src/vs/platform/agentHost/common/meta/vscode/agentRemoteSessionMeta.ts": { + "statements": { + "covered": 49, + "total": 88, + "percentage": 55.68 + }, + "branches": { + "covered": 3, + "total": 18, + "percentage": 16.66 + }, + "functions": { + "covered": 2, + "total": 7, + "percentage": 28.57 + }, + "lines": { + "covered": 49, + "total": 88, + "percentage": 55.68 + } + }, + "src/vs/platform/agentHost/common/meta/vscode/agentSandboxDiagnostics.ts": { + "statements": { + "covered": 13, + "total": 14, + "percentage": 92.85 }, "branches": { + "covered": 1, + "total": 4, + "percentage": 25 + }, + "functions": { "covered": 1, "total": 1, "percentage": 100 }, + "lines": { + "covered": 13, + "total": 14, + "percentage": 92.85 + } + }, + "src/vs/platform/agentHost/common/meta/vscode/agentSandboxPolicyMeta.ts": { + "statements": { + "covered": 63, + "total": 63, + "percentage": 100 + }, + "branches": { + "covered": 15, + "total": 34, + "percentage": 44.11 + }, "functions": { - "covered": 0, - "total": 3, - "percentage": 0 + "covered": 2, + "total": 2, + "percentage": 100 + }, + "lines": { + "covered": 63, + "total": 63, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/common/meta/vscode/agentSandboxStateMeta.ts": { + "statements": { + "covered": 34, + "total": 44, + "percentage": 77.27 + }, + "branches": { + "covered": 7, + "total": 12, + "percentage": 58.33 + }, + "functions": { + "covered": 2, + "total": 2, + "percentage": 100 }, "lines": { - "covered": 58, - "total": 82, - "percentage": 70.73 + "covered": 34, + "total": 44, + "percentage": 77.27 } }, - "src/vs/platform/agentHost/common/meta/agentPullRequestOperationMeta.ts": { + "src/vs/platform/agentHost/common/meta/vscode/agentSlashCommandOutputMeta.ts": { "statements": { - "covered": 126, - "total": 250, - "percentage": 50.4 + "covered": 19, + "total": 35, + "percentage": 54.28 }, "branches": { - "covered": 22, - "total": 35, - "percentage": 62.85 + "covered": 0, + "total": 0, + "percentage": 100 }, "functions": { - "covered": 7, - "total": 17, - "percentage": 41.17 + "covered": 0, + "total": 2, + "percentage": 0 }, "lines": { - "covered": 126, - "total": 250, - "percentage": 50.4 + "covered": 19, + "total": 35, + "percentage": 54.28 } }, - "src/vs/platform/agentHost/common/meta/agentSnapshotAttachmentMeta.ts": { + "src/vs/platform/agentHost/common/meta/vscode/agentSnapshotAttachmentMeta.ts": { "statements": { "covered": 52, "total": 52, @@ -1840,11 +3182,11 @@ "percentage": 100 } }, - "src/vs/platform/agentHost/common/meta/agentSystemNotificationMeta.ts": { + "src/vs/platform/agentHost/common/meta/vscode/agentSystemNotificationMeta.ts": { "statements": { - "covered": 84, - "total": 125, - "percentage": 67.2 + "covered": 89, + "total": 133, + "percentage": 66.91 }, "branches": { "covered": 3, @@ -1857,34 +3199,56 @@ "percentage": 0 }, "lines": { - "covered": 84, - "total": 125, - "percentage": 67.2 + "covered": 89, + "total": 133, + "percentage": 66.91 } }, - "src/vs/platform/agentHost/common/meta/agentToolCallMeta.ts": { + "src/vs/platform/agentHost/common/meta/vscode/agentToolCallMeta.ts": { "statements": { - "covered": 185, - "total": 202, - "percentage": 91.58 + "covered": 199, + "total": 220, + "percentage": 90.45 }, "branches": { - "covered": 46, - "total": 67, - "percentage": 68.65 + "covered": 50, + "total": 72, + "percentage": 69.44 }, "functions": { "covered": 7, + "total": 8, + "percentage": 87.5 + }, + "lines": { + "covered": 199, + "total": 220, + "percentage": 90.45 + } + }, + "src/vs/platform/agentHost/common/meta/vscode/agentUsageMeta.ts": { + "statements": { + "covered": 191, + "total": 271, + "percentage": 70.47 + }, + "branches": { + "covered": 24, + "total": 53, + "percentage": 45.28 + }, + "functions": { + "covered": 5, "total": 7, - "percentage": 100 + "percentage": 71.42 }, "lines": { - "covered": 185, - "total": 202, - "percentage": 91.58 + "covered": 191, + "total": 271, + "percentage": 70.47 } }, - "src/vs/platform/agentHost/common/meta/agentWorkspaceContinuationMeta.ts": { + "src/vs/platform/agentHost/common/meta/vscode/agentWorkspaceContinuationMeta.ts": { "statements": { "covered": 16, "total": 20, @@ -1906,7 +3270,7 @@ "percentage": 80 } }, - "src/vs/platform/agentHost/common/meta/clientPluginCustomizationMeta.ts": { + "src/vs/platform/agentHost/common/meta/vscode/clientPluginCustomizationMeta.ts": { "statements": { "covered": 38, "total": 56, @@ -1928,11 +3292,11 @@ "percentage": 67.85 } }, - "src/vs/platform/agentHost/common/meta/codexAccount.ts": { + "src/vs/platform/agentHost/common/meta/vscode/codexAccount.ts": { "statements": { - "covered": 49, - "total": 121, - "percentage": 40.49 + "covered": 52, + "total": 131, + "percentage": 39.69 }, "branches": { "covered": 0, @@ -1941,13 +3305,57 @@ }, "functions": { "covered": 0, - "total": 3, + "total": 4, "percentage": 0 }, "lines": { - "covered": 49, - "total": 121, - "percentage": 40.49 + "covered": 52, + "total": 131, + "percentage": 39.69 + } + }, + "src/vs/platform/agentHost/common/meta/vscode/copilotBackgroundWorkMeta.ts": { + "statements": { + "covered": 23, + "total": 26, + "percentage": 88.46 + }, + "branches": { + "covered": 2, + "total": 4, + "percentage": 50 + }, + "functions": { + "covered": 2, + "total": 3, + "percentage": 66.66 + }, + "lines": { + "covered": 23, + "total": 26, + "percentage": 88.46 + } + }, + "src/vs/platform/agentHost/common/meta/vscode/mcpCustomizationMeta.ts": { + "statements": { + "covered": 58, + "total": 59, + "percentage": 98.3 + }, + "branches": { + "covered": 16, + "total": 23, + "percentage": 69.56 + }, + "functions": { + "covered": 5, + "total": 5, + "percentage": 100 + }, + "lines": { + "covered": 58, + "total": 59, + "percentage": 98.3 } }, "src/vs/platform/agentHost/common/nonPtyShellTerminalUri.ts": { @@ -1996,8 +3404,8 @@ }, "src/vs/platform/agentHost/common/otel/agentHostOTelService.ts": { "statements": { - "covered": 128, - "total": 128, + "covered": 131, + "total": 131, "percentage": 100 }, "branches": { @@ -2007,35 +3415,35 @@ }, "functions": { "covered": 0, - "total": 12, + "total": 13, "percentage": 0 }, "lines": { - "covered": 128, - "total": 128, + "covered": 131, + "total": 131, "percentage": 100 } }, "src/vs/platform/agentHost/common/otel/agentHostTiming.ts": { "statements": { - "covered": 54, - "total": 103, - "percentage": 52.42 + "covered": 97, + "total": 114, + "percentage": 85.08 }, "branches": { - "covered": 0, - "total": 0, - "percentage": 100 + "covered": 9, + "total": 12, + "percentage": 75 }, "functions": { - "covered": 0, - "total": 1, - "percentage": 0 + "covered": 4, + "total": 4, + "percentage": 100 }, "lines": { - "covered": 54, - "total": 103, - "percentage": 52.42 + "covered": 97, + "total": 114, + "percentage": 85.08 } }, "src/vs/platform/agentHost/common/otlp/otlpLogEmitter.ts": { @@ -2062,24 +3470,24 @@ }, "src/vs/platform/agentHost/common/partialToolInput.ts": { "statements": { - "covered": 19, - "total": 26, - "percentage": 73.07 + "covered": 25, + "total": 32, + "percentage": 78.12 }, "branches": { - "covered": 5, - "total": 6, - "percentage": 83.33 + "covered": 8, + "total": 9, + "percentage": 88.88 }, "functions": { - "covered": 1, - "total": 2, - "percentage": 50 + "covered": 2, + "total": 3, + "percentage": 66.66 }, "lines": { - "covered": 19, - "total": 26, - "percentage": 73.07 + "covered": 25, + "total": 32, + "percentage": 78.12 } }, "src/vs/platform/agentHost/common/pendingRequestRegistry.ts": { @@ -2194,8 +3602,8 @@ }, "src/vs/platform/agentHost/common/sandboxConfigSchema.ts": { "statements": { - "covered": 143, - "total": 143, + "covered": 191, + "total": 191, "percentage": 100 }, "branches": { @@ -2209,8 +3617,8 @@ "percentage": 100 }, "lines": { - "covered": 143, - "total": 143, + "covered": 191, + "total": 191, "percentage": 100 } }, @@ -2282,14 +3690,14 @@ }, "src/vs/platform/agentHost/common/sessionArtifacts.ts": { "statements": { - "covered": 170, - "total": 191, - "percentage": 89 + "covered": 175, + "total": 194, + "percentage": 90.2 }, "branches": { - "covered": 22, - "total": 33, - "percentage": 66.66 + "covered": 26, + "total": 38, + "percentage": 68.42 }, "functions": { "covered": 7, @@ -2297,37 +3705,37 @@ "percentage": 77.77 }, "lines": { - "covered": 170, - "total": 191, - "percentage": 89 + "covered": 175, + "total": 194, + "percentage": 90.2 } }, "src/vs/platform/agentHost/common/sessionConfigKeys.ts": { "statements": { - "covered": 104, - "total": 121, - "percentage": 85.95 + "covered": 118, + "total": 135, + "percentage": 87.4 }, "branches": { - "covered": 2, - "total": 2, - "percentage": 100 + "covered": 5, + "total": 6, + "percentage": 83.33 }, "functions": { - "covered": 1, - "total": 3, - "percentage": 33.33 + "covered": 2, + "total": 4, + "percentage": 50 }, "lines": { - "covered": 104, - "total": 121, - "percentage": 85.95 + "covered": 118, + "total": 135, + "percentage": 87.4 } }, "src/vs/platform/agentHost/common/sessionDataService.ts": { "statements": { - "covered": 639, - "total": 639, + "covered": 636, + "total": 636, "percentage": 100 }, "branches": { @@ -2341,8 +3749,8 @@ "percentage": 100 }, "lines": { - "covered": 639, - "total": 639, + "covered": 636, + "total": 636, "percentage": 100 } }, @@ -2392,24 +3800,24 @@ }, "src/vs/platform/agentHost/common/state/agentSubscription.ts": { "statements": { - "covered": 658, - "total": 1337, - "percentage": 49.21 + "covered": 681, + "total": 1436, + "percentage": 47.42 }, "branches": { - "covered": 10, - "total": 13, - "percentage": 76.92 + "covered": 9, + "total": 12, + "percentage": 75 }, "functions": { "covered": 1, - "total": 97, - "percentage": 1.03 + "total": 105, + "percentage": 0.95 }, "lines": { - "covered": 658, - "total": 1337, - "percentage": 49.21 + "covered": 681, + "total": 1436, + "percentage": 47.42 } }, "src/vs/platform/agentHost/common/state/chatAttachmentContext.ts": { @@ -2458,8 +3866,8 @@ }, "src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts": { "statements": { - "covered": 428, - "total": 428, + "covered": 465, + "total": 465, "percentage": 100 }, "branches": { @@ -2473,15 +3881,15 @@ "percentage": 100 }, "lines": { - "covered": 428, - "total": 428, + "covered": 465, + "total": 465, "percentage": 100 } }, "src/vs/platform/agentHost/common/state/protocol/actions.ts": { "statements": { - "covered": 18, - "total": 18, + "covered": 19, + "total": 19, "percentage": 100 }, "branches": { @@ -2495,8 +3903,8 @@ "percentage": 100 }, "lines": { - "covered": 18, - "total": 18, + "covered": 19, + "total": 19, "percentage": 100 } }, @@ -2566,6 +3974,28 @@ "percentage": 89.58 } }, + "src/vs/platform/agentHost/common/state/protocol/channels-canvas/reducer.ts": { + "statements": { + "covered": 20, + "total": 27, + "percentage": 74.07 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 1, + "percentage": 0 + }, + "lines": { + "covered": 20, + "total": 27, + "percentage": 74.07 + } + }, "src/vs/platform/agentHost/common/state/protocol/channels-changeset/reducer.ts": { "statements": { "covered": 90, @@ -2590,14 +4020,14 @@ }, "src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts": { "statements": { - "covered": 760, - "total": 916, - "percentage": 82.96 + "covered": 790, + "total": 952, + "percentage": 82.98 }, "branches": { - "covered": 179, - "total": 251, - "percentage": 71.31 + "covered": 204, + "total": 274, + "percentage": 74.45 }, "functions": { "covered": 17, @@ -2605,9 +4035,9 @@ "percentage": 100 }, "lines": { - "covered": 760, - "total": 916, - "percentage": 82.96 + "covered": 790, + "total": 952, + "percentage": 82.98 } }, "src/vs/platform/agentHost/common/state/protocol/channels-resource-watch/reducer.ts": { @@ -2656,14 +4086,14 @@ }, "src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts": { "statements": { - "covered": 334, - "total": 446, - "percentage": 74.88 + "covered": 338, + "total": 474, + "percentage": 71.3 }, "branches": { - "covered": 77, - "total": 105, - "percentage": 73.33 + "covered": 78, + "total": 108, + "percentage": 72.22 }, "functions": { "covered": 6, @@ -2671,9 +4101,9 @@ "percentage": 100 }, "lines": { - "covered": 334, - "total": 446, - "percentage": 74.88 + "covered": 338, + "total": 474, + "percentage": 71.3 } }, "src/vs/platform/agentHost/common/state/protocol/channels-terminal/reducer.ts": { @@ -2722,8 +4152,8 @@ }, "src/vs/platform/agentHost/common/state/protocol/common/actions.ts": { "statements": { - "covered": 267, - "total": 267, + "covered": 285, + "total": 285, "percentage": 100 }, "branches": { @@ -2737,15 +4167,15 @@ "percentage": 100 }, "lines": { - "covered": 267, - "total": 267, + "covered": 285, + "total": 285, "percentage": 100 } }, "src/vs/platform/agentHost/common/state/protocol/common/commands.ts": { "statements": { - "covered": 1190, - "total": 1190, + "covered": 1206, + "total": 1206, "percentage": 100 }, "branches": { @@ -2759,8 +4189,8 @@ "percentage": 100 }, "lines": { - "covered": 1190, - "total": 1190, + "covered": 1206, + "total": 1206, "percentage": 100 } }, @@ -2920,8 +4350,8 @@ }, "src/vs/platform/agentHost/common/state/protocol/reducers.ts": { "statements": { - "covered": 18, - "total": 18, + "covered": 19, + "total": 19, "percentage": 100 }, "branches": { @@ -2935,15 +4365,15 @@ "percentage": 100 }, "lines": { - "covered": 18, - "total": 18, + "covered": 19, + "total": 19, "percentage": 100 } }, "src/vs/platform/agentHost/common/state/protocol/state.ts": { "statements": { - "covered": 19, - "total": 19, + "covered": 20, + "total": 20, "percentage": 100 }, "branches": { @@ -2957,8 +4387,8 @@ "percentage": 100 }, "lines": { - "covered": 19, - "total": 19, + "covered": 20, + "total": 20, "percentage": 100 } }, @@ -2986,9 +4416,9 @@ }, "src/vs/platform/agentHost/common/state/protocol/version/registry.ts": { "statements": { - "covered": 219, - "total": 225, - "percentage": 97.33 + "covered": 228, + "total": 234, + "percentage": 97.43 }, "branches": { "covered": 2, @@ -3001,9 +4431,9 @@ "percentage": 50 }, "lines": { - "covered": 219, - "total": 225, - "percentage": 97.33 + "covered": 228, + "total": 234, + "percentage": 97.43 } }, "src/vs/platform/agentHost/common/state/protocolUpgrade.ts": { @@ -3030,8 +4460,8 @@ }, "src/vs/platform/agentHost/common/state/sessionActions.ts": { "statements": { - "covered": 275, - "total": 275, + "covered": 276, + "total": 276, "percentage": 100 }, "branches": { @@ -3045,8 +4475,8 @@ "percentage": 100 }, "lines": { - "covered": 275, - "total": 275, + "covered": 276, + "total": 276, "percentage": 100 } }, @@ -3096,24 +4526,24 @@ }, "src/vs/platform/agentHost/common/state/sessionState.ts": { "statements": { - "covered": 2042, - "total": 2541, - "percentage": 80.36 + "covered": 1927, + "total": 2298, + "percentage": 83.85 }, "branches": { - "covered": 369, - "total": 504, - "percentage": 73.21 + "covered": 353, + "total": 465, + "percentage": 75.91 }, "functions": { - "covered": 98, - "total": 136, - "percentage": 72.05 + "covered": 100, + "total": 129, + "percentage": 77.51 }, "lines": { - "covered": 2042, - "total": 2541, - "percentage": 80.36 + "covered": 1927, + "total": 2298, + "percentage": 83.85 } }, "src/vs/platform/agentHost/common/state/sessionWorkingDirectories.ts": { @@ -3182,25 +4612,47 @@ "percentage": 100 } }, - "src/vs/platform/agentHost/common/workspacelessScratchDir.ts": { + "src/vs/platform/agentHost/common/vscodeSandboxPaths.ts": { "statements": { - "covered": 11, - "total": 11, + "covered": 22, + "total": 22, "percentage": 100 }, "branches": { + "covered": 3, + "total": 5, + "percentage": 60 + }, + "functions": { "covered": 1, "total": 1, "percentage": 100 }, + "lines": { + "covered": 22, + "total": 22, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/common/workspacelessScratchDir.ts": { + "statements": { + "covered": 20, + "total": 20, + "percentage": 100 + }, + "branches": { + "covered": 2, + "total": 2, + "percentage": 100 + }, "functions": { - "covered": 1, - "total": 1, + "covered": 2, + "total": 2, "percentage": 100 }, "lines": { - "covered": 11, - "total": 11, + "covered": 20, + "total": 20, "percentage": 100 } }, @@ -3228,14 +4680,14 @@ }, "src/vs/platform/agentHost/node/activeClientState.ts": { "statements": { - "covered": 160, + "covered": 166, "total": 195, - "percentage": 82.05 + "percentage": 85.12 }, "branches": { - "covered": 13, - "total": 20, - "percentage": 65 + "covered": 20, + "total": 24, + "percentage": 83.33 }, "functions": { "covered": 7, @@ -3243,9 +4695,9 @@ "percentage": 43.75 }, "lines": { - "covered": 160, + "covered": 166, "total": 195, - "percentage": 82.05 + "percentage": 85.12 } }, "src/vs/platform/agentHost/node/agentChatBackings.ts": { @@ -3294,68 +4746,90 @@ }, "src/vs/platform/agentHost/node/agentConfigurationService.ts": { "statements": { - "covered": 395, - "total": 431, - "percentage": 91.64 + "covered": 461, + "total": 511, + "percentage": 90.21 }, "branches": { - "covered": 49, - "total": 67, - "percentage": 73.13 + "covered": 76, + "total": 93, + "percentage": 81.72 }, "functions": { - "covered": 18, - "total": 19, - "percentage": 94.73 + "covered": 22, + "total": 24, + "percentage": 91.66 }, "lines": { - "covered": 395, - "total": 431, - "percentage": 91.64 + "covered": 461, + "total": 511, + "percentage": 90.21 } }, "src/vs/platform/agentHost/node/agentHostAuthenticationService.ts": { "statements": { - "covered": 141, - "total": 198, - "percentage": 71.21 + "covered": 189, + "total": 258, + "percentage": 73.25 }, "branches": { - "covered": 22, - "total": 36, - "percentage": 61.11 + "covered": 29, + "total": 48, + "percentage": 60.41 }, "functions": { - "covered": 6, - "total": 7, - "percentage": 85.71 + "covered": 9, + "total": 10, + "percentage": 90 }, "lines": { - "covered": 141, - "total": 198, - "percentage": 71.21 + "covered": 189, + "total": 258, + "percentage": 73.25 + } + }, + "src/vs/platform/agentHost/node/agentHostAutomationCustomizations.ts": { + "statements": { + "covered": 68, + "total": 156, + "percentage": 43.58 + }, + "branches": { + "covered": 5, + "total": 14, + "percentage": 35.71 + }, + "functions": { + "covered": 5, + "total": 5, + "percentage": 100 + }, + "lines": { + "covered": 68, + "total": 156, + "percentage": 43.58 } }, "src/vs/platform/agentHost/node/agentHostAutomationService.ts": { "statements": { - "covered": 856, - "total": 1137, - "percentage": 75.28 + "covered": 946, + "total": 1250, + "percentage": 75.68 }, "branches": { - "covered": 195, - "total": 269, - "percentage": 72.49 + "covered": 230, + "total": 314, + "percentage": 73.24 }, "functions": { - "covered": 60, - "total": 64, - "percentage": 93.75 + "covered": 62, + "total": 67, + "percentage": 92.53 }, "lines": { - "covered": 856, - "total": 1137, - "percentage": 75.28 + "covered": 946, + "total": 1250, + "percentage": 75.68 } }, "src/vs/platform/agentHost/node/agentHostAutomationTelemetry.ts": { @@ -3404,31 +4878,31 @@ }, "src/vs/platform/agentHost/node/agentHostBootstrap.ts": { "statements": { - "covered": 217, - "total": 222, - "percentage": 97.74 + "covered": 224, + "total": 229, + "percentage": 97.81 }, "branches": { - "covered": 13, - "total": 15, - "percentage": 86.66 + "covered": 12, + "total": 14, + "percentage": 85.71 }, "functions": { - "covered": 8, - "total": 9, - "percentage": 88.88 + "covered": 6, + "total": 7, + "percentage": 85.71 }, "lines": { - "covered": 217, - "total": 222, - "percentage": 97.74 + "covered": 224, + "total": 229, + "percentage": 97.81 } }, "src/vs/platform/agentHost/node/agentHostBranchChangesetScope.ts": { "statements": { - "covered": 114, - "total": 129, - "percentage": 88.37 + "covered": 131, + "total": 174, + "percentage": 75.28 }, "branches": { "covered": 39, @@ -3437,47 +4911,47 @@ }, "functions": { "covered": 5, - "total": 5, - "percentage": 100 + "total": 8, + "percentage": 62.5 }, "lines": { - "covered": 114, - "total": 129, - "percentage": 88.37 + "covered": 131, + "total": 174, + "percentage": 75.28 } }, "src/vs/platform/agentHost/node/agentHostCatalogListReader.ts": { "statements": { - "covered": 85, - "total": 104, - "percentage": 81.73 + "covered": 122, + "total": 160, + "percentage": 76.25 }, "branches": { - "covered": 15, - "total": 24, - "percentage": 62.5 + "covered": 29, + "total": 40, + "percentage": 72.5 }, "functions": { - "covered": 4, - "total": 4, - "percentage": 100 + "covered": 6, + "total": 8, + "percentage": 75 }, "lines": { - "covered": 85, - "total": 104, - "percentage": 81.73 + "covered": 122, + "total": 160, + "percentage": 76.25 } }, "src/vs/platform/agentHost/node/agentHostCatalogProjection.ts": { "statements": { - "covered": 424, - "total": 517, - "percentage": 82.01 + "covered": 446, + "total": 542, + "percentage": 82.28 }, "branches": { "covered": 51, - "total": 91, - "percentage": 56.04 + "total": 92, + "percentage": 55.43 }, "functions": { "covered": 21, @@ -3485,87 +4959,87 @@ "percentage": 77.77 }, "lines": { - "covered": 424, - "total": 517, - "percentage": 82.01 + "covered": 446, + "total": 542, + "percentage": 82.28 } }, "src/vs/platform/agentHost/node/agentHostCatalogReconciliationService.ts": { "statements": { - "covered": 517, - "total": 844, - "percentage": 61.25 + "covered": 525, + "total": 781, + "percentage": 67.22 }, "branches": { - "covered": 133, - "total": 181, - "percentage": 73.48 + "covered": 139, + "total": 185, + "percentage": 75.13 }, "functions": { "covered": 28, - "total": 39, - "percentage": 71.79 + "total": 37, + "percentage": 75.67 }, "lines": { - "covered": 517, - "total": 844, - "percentage": 61.25 + "covered": 525, + "total": 781, + "percentage": 67.22 } }, "src/vs/platform/agentHost/node/agentHostCatalogSourceResolver.ts": { "statements": { - "covered": 406, - "total": 494, - "percentage": 82.18 + "covered": 454, + "total": 547, + "percentage": 82.99 }, "branches": { - "covered": 189, - "total": 232, - "percentage": 81.46 + "covered": 245, + "total": 297, + "percentage": 82.49 }, "functions": { - "covered": 19, - "total": 23, - "percentage": 82.6 + "covered": 21, + "total": 25, + "percentage": 84 }, "lines": { - "covered": 406, - "total": 494, - "percentage": 82.18 + "covered": 454, + "total": 547, + "percentage": 82.99 } }, "src/vs/platform/agentHost/node/agentHostCatalogSyncService.ts": { "statements": { - "covered": 288, - "total": 447, - "percentage": 64.42 + "covered": 303, + "total": 552, + "percentage": 54.89 }, "branches": { - "covered": 74, - "total": 89, - "percentage": 83.14 + "covered": 81, + "total": 95, + "percentage": 85.26 }, "functions": { "covered": 15, - "total": 20, - "percentage": 75 + "total": 22, + "percentage": 68.18 }, "lines": { - "covered": 288, - "total": 447, - "percentage": 64.42 + "covered": 303, + "total": 552, + "percentage": 54.89 } }, "src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts": { "statements": { - "covered": 575, - "total": 655, - "percentage": 87.78 + "covered": 580, + "total": 663, + "percentage": 87.48 }, "branches": { - "covered": 139, - "total": 174, - "percentage": 79.88 + "covered": 142, + "total": 175, + "percentage": 81.14 }, "functions": { "covered": 27, @@ -3573,9 +5047,9 @@ "percentage": 96.42 }, "lines": { - "covered": 575, - "total": 655, - "percentage": 87.78 + "covered": 580, + "total": 663, + "percentage": 87.48 } }, "src/vs/platform/agentHost/node/agentHostChangesetFileMonitorCoordinator.ts": { @@ -3585,9 +5059,9 @@ "percentage": 91.86 }, "branches": { - "covered": 107, - "total": 126, - "percentage": 84.92 + "covered": 108, + "total": 127, + "percentage": 85.03 }, "functions": { "covered": 29, @@ -3602,14 +5076,14 @@ }, "src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts": { "statements": { - "covered": 371, - "total": 411, - "percentage": 90.26 + "covered": 374, + "total": 414, + "percentage": 90.33 }, "branches": { - "covered": 94, + "covered": 92, "total": 127, - "percentage": 74.01 + "percentage": 72.44 }, "functions": { "covered": 19, @@ -3617,31 +5091,31 @@ "percentage": 100 }, "lines": { - "covered": 371, - "total": 411, - "percentage": 90.26 + "covered": 374, + "total": 414, + "percentage": 90.33 } }, "src/vs/platform/agentHost/node/agentHostChangesetService.ts": { "statements": { - "covered": 1444, - "total": 1936, - "percentage": 74.58 + "covered": 1623, + "total": 2204, + "percentage": 73.63 }, "branches": { - "covered": 352, - "total": 453, - "percentage": 77.7 + "covered": 419, + "total": 536, + "percentage": 78.17 }, "functions": { - "covered": 72, - "total": 88, - "percentage": 81.81 + "covered": 83, + "total": 100, + "percentage": 83 }, "lines": { - "covered": 1444, - "total": 1936, - "percentage": 74.58 + "covered": 1623, + "total": 2204, + "percentage": 73.63 } }, "src/vs/platform/agentHost/node/agentHostChangesetStateCache.ts": { @@ -3756,14 +5230,14 @@ }, "src/vs/platform/agentHost/node/agentHostChatContributionsService.ts": { "statements": { - "covered": 266, + "covered": 264, "total": 294, - "percentage": 90.47 + "percentage": 89.79 }, "branches": { - "covered": 73, - "total": 86, - "percentage": 84.88 + "covered": 76, + "total": 90, + "percentage": 84.44 }, "functions": { "covered": 22, @@ -3771,9 +5245,31 @@ "percentage": 95.65 }, "lines": { - "covered": 266, + "covered": 264, "total": 294, - "percentage": 90.47 + "percentage": 89.79 + } + }, + "src/vs/platform/agentHost/node/agentHostChatInputService.ts": { + "statements": { + "covered": 125, + "total": 137, + "percentage": 91.24 + }, + "branches": { + "covered": 37, + "total": 45, + "percentage": 82.22 + }, + "functions": { + "covered": 6, + "total": 7, + "percentage": 85.71 + }, + "lines": { + "covered": 125, + "total": 137, + "percentage": 91.24 } }, "src/vs/platform/agentHost/node/agentHostCheckoutOperationHandler.ts": { @@ -3822,46 +5318,46 @@ }, "src/vs/platform/agentHost/node/agentHostCheckpointService.ts": { "statements": { - "covered": 368, - "total": 508, - "percentage": 72.44 + "covered": 390, + "total": 538, + "percentage": 72.49 }, "branches": { - "covered": 100, - "total": 126, - "percentage": 79.36 + "covered": 115, + "total": 144, + "percentage": 79.86 }, - "functions": { - "covered": 17, - "total": 21, - "percentage": 80.95 + "functions": { + "covered": 18, + "total": 22, + "percentage": 81.81 }, "lines": { - "covered": 368, - "total": 508, - "percentage": 72.44 + "covered": 390, + "total": 538, + "percentage": 72.49 } }, "src/vs/platform/agentHost/node/agentHostClientConnectionService.ts": { "statements": { - "covered": 83, - "total": 99, - "percentage": 83.83 + "covered": 100, + "total": 125, + "percentage": 80 }, "branches": { - "covered": 10, - "total": 11, - "percentage": 90.9 + "covered": 11, + "total": 13, + "percentage": 84.61 }, "functions": { - "covered": 4, - "total": 6, - "percentage": 66.66 + "covered": 5, + "total": 8, + "percentage": 62.5 }, "lines": { - "covered": 83, - "total": 99, - "percentage": 83.83 + "covered": 100, + "total": 125, + "percentage": 80 } }, "src/vs/platform/agentHost/node/agentHostCommitOperationHandler.ts": { @@ -3893,9 +5389,9 @@ "percentage": 100 }, "branches": { - "covered": 16, - "total": 16, - "percentage": 100 + "covered": 14, + "total": 15, + "percentage": 93.33 }, "functions": { "covered": 6, @@ -3910,9 +5406,9 @@ }, "src/vs/platform/agentHost/node/agentHostCompletions.ts": { "statements": { - "covered": 129, - "total": 133, - "percentage": 96.99 + "covered": 131, + "total": 135, + "percentage": 97.03 }, "branches": { "covered": 11, @@ -3925,9 +5421,9 @@ "percentage": 100 }, "lines": { - "covered": 129, - "total": 133, - "percentage": 96.99 + "covered": 131, + "total": 135, + "percentage": 97.03 } }, "src/vs/platform/agentHost/node/agentHostContributions.ts": { @@ -3954,14 +5450,14 @@ }, "src/vs/platform/agentHost/node/agentHostCustomizationEnablementService.ts": { "statements": { - "covered": 576, + "covered": 578, "total": 732, - "percentage": 78.68 + "percentage": 78.96 }, "branches": { - "covered": 108, - "total": 150, - "percentage": 72 + "covered": 111, + "total": 152, + "percentage": 73.02 }, "functions": { "covered": 42, @@ -3969,31 +5465,31 @@ "percentage": 89.36 }, "lines": { - "covered": 576, + "covered": 578, "total": 732, - "percentage": 78.68 + "percentage": 78.96 } }, "src/vs/platform/agentHost/node/agentHostDatabase.ts": { "statements": { - "covered": 1188, - "total": 1696, - "percentage": 70.04 + "covered": 1246, + "total": 1774, + "percentage": 70.23 }, "branches": { - "covered": 175, - "total": 232, - "percentage": 75.43 + "covered": 208, + "total": 274, + "percentage": 75.91 }, "functions": { - "covered": 61, - "total": 87, - "percentage": 70.11 + "covered": 64, + "total": 89, + "percentage": 71.91 }, "lines": { - "covered": 1188, - "total": 1696, - "percentage": 70.04 + "covered": 1246, + "total": 1774, + "percentage": 70.23 } }, "src/vs/platform/agentHost/node/agentHostDebugLogs.ts": { @@ -4003,9 +5499,9 @@ "percentage": 87.85 }, "branches": { - "covered": 32, - "total": 43, - "percentage": 74.41 + "covered": 31, + "total": 42, + "percentage": 73.8 }, "functions": { "covered": 9, @@ -4113,9 +5609,9 @@ "percentage": 90.81 }, "branches": { - "covered": 27, - "total": 36, - "percentage": 75 + "covered": 26, + "total": 35, + "percentage": 74.28 }, "functions": { "covered": 14, @@ -4130,24 +5626,46 @@ }, "src/vs/platform/agentHost/node/agentHostGitHubEndpointService.ts": { "statements": { - "covered": 125, - "total": 127, - "percentage": 98.42 + "covered": 128, + "total": 128, + "percentage": 100 }, "branches": { - "covered": 13, - "total": 13, + "covered": 14, + "total": 14, "percentage": 100 }, "functions": { - "covered": 7, + "covered": 8, "total": 8, - "percentage": 87.5 + "percentage": 100 }, "lines": { - "covered": 125, - "total": 127, - "percentage": 98.42 + "covered": 128, + "total": 128, + "percentage": 100 + } + }, + "src/vs/platform/agentHost/node/agentHostGitHubService.ts": { + "statements": { + "covered": 100, + "total": 131, + "percentage": 76.33 + }, + "branches": { + "covered": 10, + "total": 15, + "percentage": 66.66 + }, + "functions": { + "covered": 5, + "total": 6, + "percentage": 83.33 + }, + "lines": { + "covered": 100, + "total": 131, + "percentage": 76.33 } }, "src/vs/platform/agentHost/node/agentHostGitHubTelemetryRouter.ts": { @@ -4174,46 +5692,46 @@ }, "src/vs/platform/agentHost/node/agentHostGitService.ts": { "statements": { - "covered": 1526, - "total": 1787, - "percentage": 85.39 + "covered": 1732, + "total": 2146, + "percentage": 80.7 }, "branches": { - "covered": 349, - "total": 464, - "percentage": 75.21 + "covered": 419, + "total": 549, + "percentage": 76.32 }, "functions": { - "covered": 78, - "total": 86, - "percentage": 90.69 + "covered": 87, + "total": 102, + "percentage": 85.29 }, "lines": { - "covered": 1526, - "total": 1787, - "percentage": 85.39 + "covered": 1732, + "total": 2146, + "percentage": 80.7 } }, "src/vs/platform/agentHost/node/agentHostGitStateService.ts": { "statements": { - "covered": 393, - "total": 610, - "percentage": 64.42 + "covered": 482, + "total": 961, + "percentage": 50.15 }, "branches": { - "covered": 116, - "total": 155, - "percentage": 74.83 + "covered": 131, + "total": 181, + "percentage": 72.37 }, "functions": { - "covered": 17, - "total": 28, - "percentage": 60.71 + "covered": 19, + "total": 37, + "percentage": 51.35 }, "lines": { - "covered": 393, - "total": 610, - "percentage": 64.42 + "covered": 482, + "total": 961, + "percentage": 50.15 } }, "src/vs/platform/agentHost/node/agentHostHeadlessTerminal.ts": { @@ -4240,9 +5758,9 @@ }, "src/vs/platform/agentHost/node/agentHostInputRequestTracker.ts": { "statements": { - "covered": 146, - "total": 164, - "percentage": 89.02 + "covered": 151, + "total": 169, + "percentage": 89.34 }, "branches": { "covered": 47, @@ -4251,35 +5769,35 @@ }, "functions": { "covered": 13, - "total": 14, - "percentage": 92.85 + "total": 16, + "percentage": 81.25 }, "lines": { - "covered": 146, - "total": 164, - "percentage": 89.02 + "covered": 151, + "total": 169, + "percentage": 89.34 } }, "src/vs/platform/agentHost/node/agentHostLocalTurns.ts": { "statements": { - "covered": 161, - "total": 193, - "percentage": 83.41 + "covered": 191, + "total": 229, + "percentage": 83.4 }, "branches": { - "covered": 19, - "total": 29, - "percentage": 65.51 + "covered": 24, + "total": 36, + "percentage": 66.66 }, "functions": { - "covered": 10, - "total": 12, - "percentage": 83.33 + "covered": 13, + "total": 15, + "percentage": 86.66 }, "lines": { - "covered": 161, - "total": 193, - "percentage": 83.41 + "covered": 191, + "total": 229, + "percentage": 83.4 } }, "src/vs/platform/agentHost/node/agentHostLockfile.ts": { @@ -4306,24 +5824,24 @@ }, "src/vs/platform/agentHost/node/agentHostManagedSettingsService.ts": { "statements": { - "covered": 42, + "covered": 74, "total": 76, - "percentage": 55.26 + "percentage": 97.36 }, "branches": { - "covered": 3, - "total": 4, - "percentage": 75 + "covered": 17, + "total": 18, + "percentage": 94.44 }, "functions": { - "covered": 3, + "covered": 5, "total": 5, - "percentage": 60 + "percentage": 100 }, "lines": { - "covered": 42, + "covered": 74, "total": 76, - "percentage": 55.26 + "percentage": 97.36 } }, "src/vs/platform/agentHost/node/agentHostMergeOperationHandler.ts": { @@ -4416,24 +5934,24 @@ }, "src/vs/platform/agentHost/node/agentHostPeerChatStore.ts": { "statements": { - "covered": 514, - "total": 697, - "percentage": 73.74 + "covered": 597, + "total": 940, + "percentage": 63.51 }, "branches": { - "covered": 179, - "total": 249, - "percentage": 71.88 + "covered": 253, + "total": 319, + "percentage": 79.31 }, "functions": { - "covered": 33, - "total": 37, - "percentage": 89.18 + "covered": 36, + "total": 43, + "percentage": 83.72 }, "lines": { - "covered": 514, - "total": 697, - "percentage": 73.74 + "covered": 597, + "total": 940, + "percentage": 63.51 } }, "src/vs/platform/agentHost/node/agentHostPromptCache.ts": { @@ -4460,14 +5978,14 @@ }, "src/vs/platform/agentHost/node/agentHostProviderService.ts": { "statements": { - "covered": 222, + "covered": 220, "total": 247, - "percentage": 89.87 + "percentage": 89.06 }, "branches": { - "covered": 39, - "total": 56, - "percentage": 69.64 + "covered": 38, + "total": 55, + "percentage": 69.09 }, "functions": { "covered": 15, @@ -4475,9 +5993,9 @@ "percentage": 100 }, "lines": { - "covered": 222, + "covered": 220, "total": 247, - "percentage": 89.87 + "percentage": 89.06 } }, "src/vs/platform/agentHost/node/agentHostProxyResolver.ts": { @@ -4504,9 +6022,9 @@ }, "src/vs/platform/agentHost/node/agentHostPullRequestAssociationResolver.ts": { "statements": { - "covered": 113, - "total": 368, - "percentage": 30.7 + "covered": 128, + "total": 398, + "percentage": 32.16 }, "branches": { "covered": 3, @@ -4515,20 +6033,20 @@ }, "functions": { "covered": 2, - "total": 22, - "percentage": 9.09 + "total": 24, + "percentage": 8.33 }, "lines": { - "covered": 113, - "total": 368, - "percentage": 30.7 + "covered": 128, + "total": 398, + "percentage": 32.16 } }, "src/vs/platform/agentHost/node/agentHostPullRequestLifecycleOperationHandler.ts": { "statements": { - "covered": 76, - "total": 199, - "percentage": 38.19 + "covered": 78, + "total": 203, + "percentage": 38.42 }, "branches": { "covered": 1, @@ -4541,16 +6059,16 @@ "percentage": 12.5 }, "lines": { - "covered": 76, - "total": 199, - "percentage": 38.19 + "covered": 78, + "total": 203, + "percentage": 38.42 } }, "src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts": { "statements": { - "covered": 215, - "total": 743, - "percentage": 28.93 + "covered": 239, + "total": 786, + "percentage": 30.4 }, "branches": { "covered": 8, @@ -4559,20 +6077,20 @@ }, "functions": { "covered": 7, - "total": 26, - "percentage": 26.92 + "total": 27, + "percentage": 25.92 }, "lines": { - "covered": 215, - "total": 743, - "percentage": 28.93 + "covered": 239, + "total": 786, + "percentage": 30.4 } }, "src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts": { "statements": { "covered": 190, - "total": 286, - "percentage": 66.43 + "total": 287, + "percentage": 66.2 }, "branches": { "covered": 40, @@ -4586,30 +6104,30 @@ }, "lines": { "covered": 190, - "total": 286, - "percentage": 66.43 + "total": 287, + "percentage": 66.2 } }, "src/vs/platform/agentHost/node/agentHostPullRequestStatusService.ts": { "statements": { - "covered": 336, - "total": 651, - "percentage": 51.61 + "covered": 328, + "total": 642, + "percentage": 51.09 }, "branches": { - "covered": 61, - "total": 73, - "percentage": 83.56 + "covered": 63, + "total": 76, + "percentage": 82.89 }, "functions": { - "covered": 13, - "total": 32, - "percentage": 40.62 + "covered": 14, + "total": 31, + "percentage": 45.16 }, "lines": { - "covered": 336, - "total": 651, - "percentage": 51.61 + "covered": 328, + "total": 642, + "percentage": 51.09 } }, "src/vs/platform/agentHost/node/agentHostRenameCommand.ts": { @@ -4678,38 +6196,60 @@ "percentage": 73.05 } }, + "src/vs/platform/agentHost/node/agentHostResources.ts": { + "statements": { + "covered": 47, + "total": 50, + "percentage": 94 + }, + "branches": { + "covered": 8, + "total": 18, + "percentage": 44.44 + }, + "functions": { + "covered": 3, + "total": 3, + "percentage": 100 + }, + "lines": { + "covered": 47, + "total": 50, + "percentage": 94 + } + }, "src/vs/platform/agentHost/node/agentHostRestrictedTelemetry.ts": { "statements": { - "covered": 174, + "covered": 186, "total": 319, - "percentage": 54.54 + "percentage": 58.3 }, "branches": { - "covered": 2, - "total": 7, - "percentage": 28.57 + "covered": 11, + "total": 13, + "percentage": 84.61 }, "functions": { - "covered": 1, + "covered": 2, "total": 15, - "percentage": 6.66 + "percentage": 13.33 }, "lines": { - "covered": 174, + "covered": 186, "total": 319, - "percentage": 54.54 + "percentage": 58.3 } }, "src/vs/platform/agentHost/node/agentHostReviewService.ts": { "statements": { - "covered": 254, - "total": 329, - "percentage": 77.2 + "covered": 260, + "total": 337, + "percentage": 77.15 }, "branches": { - "covered": 56, - "total": 80, - "percentage": 70 + "covered": 64, + "total": 90, + "percentage": 71.11 }, "functions": { "covered": 9, @@ -4717,9 +6257,9 @@ "percentage": 69.23 }, "lines": { - "covered": 254, - "total": 329, - "percentage": 77.2 + "covered": 260, + "total": 337, + "percentage": 77.15 } }, "src/vs/platform/agentHost/node/agentHostServerMain.ts": { @@ -4729,9 +6269,9 @@ "percentage": 88.05 }, "branches": { - "covered": 26, - "total": 40, - "percentage": 65 + "covered": 27, + "total": 41, + "percentage": 65.85 }, "functions": { "covered": 6, @@ -4746,14 +6286,14 @@ }, "src/vs/platform/agentHost/node/agentHostServices.ts": { "statements": { - "covered": 148, - "total": 149, - "percentage": 99.32 + "covered": 151, + "total": 152, + "percentage": 99.34 }, "branches": { "covered": 2, - "total": 4, - "percentage": 50 + "total": 5, + "percentage": 40 }, "functions": { "covered": 2, @@ -4761,9 +6301,9 @@ "percentage": 100 }, "lines": { - "covered": 148, - "total": 149, - "percentage": 99.32 + "covered": 151, + "total": 152, + "percentage": 99.34 } }, "src/vs/platform/agentHost/node/agentHostSessionLifecycle.ts": { @@ -4795,9 +6335,9 @@ "percentage": 91.2 }, "branches": { - "covered": 58, - "total": 68, - "percentage": 85.29 + "covered": 59, + "total": 69, + "percentage": 85.5 }, "functions": { "covered": 19, @@ -4839,9 +6379,9 @@ "percentage": 91.57 }, "branches": { - "covered": 9, - "total": 11, - "percentage": 81.81 + "covered": 13, + "total": 15, + "percentage": 86.66 }, "functions": { "covered": 1, @@ -4856,24 +6396,24 @@ }, "src/vs/platform/agentHost/node/agentHostSessionTitleController.ts": { "statements": { - "covered": 791, - "total": 1083, - "percentage": 73.03 + "covered": 823, + "total": 1081, + "percentage": 76.13 }, "branches": { - "covered": 164, - "total": 228, - "percentage": 71.92 + "covered": 170, + "total": 248, + "percentage": 68.54 }, "functions": { - "covered": 36, + "covered": 40, "total": 51, - "percentage": 70.58 + "percentage": 78.43 }, "lines": { - "covered": 791, - "total": 1083, - "percentage": 73.03 + "covered": 823, + "total": 1081, + "percentage": 76.13 } }, "src/vs/platform/agentHost/node/agentHostSessionTitleSignal.ts": { @@ -4900,14 +6440,14 @@ }, "src/vs/platform/agentHost/node/agentHostSessionsV2MigrationService.ts": { "statements": { - "covered": 231, - "total": 335, - "percentage": 68.95 + "covered": 233, + "total": 337, + "percentage": 69.13 }, "branches": { - "covered": 57, - "total": 86, - "percentage": 66.27 + "covered": 58, + "total": 87, + "percentage": 66.66 }, "functions": { "covered": 6, @@ -4915,9 +6455,9 @@ "percentage": 60 }, "lines": { - "covered": 231, - "total": 335, - "percentage": 68.95 + "covered": 233, + "total": 337, + "percentage": 69.13 } }, "src/vs/platform/agentHost/node/agentHostShellUtils.ts": { @@ -5008,26 +6548,48 @@ "percentage": 89.53 } }, + "src/vs/platform/agentHost/node/agentHostStartupPerformance.ts": { + "statements": { + "covered": 340, + "total": 354, + "percentage": 96.04 + }, + "branches": { + "covered": 47, + "total": 56, + "percentage": 83.92 + }, + "functions": { + "covered": 19, + "total": 22, + "percentage": 86.36 + }, + "lines": { + "covered": 340, + "total": 354, + "percentage": 96.04 + } + }, "src/vs/platform/agentHost/node/agentHostStateManager.ts": { "statements": { - "covered": 2016, - "total": 2268, - "percentage": 88.88 + "covered": 2118, + "total": 2419, + "percentage": 87.55 }, "branches": { - "covered": 379, - "total": 457, - "percentage": 82.93 + "covered": 440, + "total": 530, + "percentage": 83.01 }, "functions": { - "covered": 93, - "total": 107, - "percentage": 86.91 + "covered": 95, + "total": 111, + "percentage": 85.58 }, "lines": { - "covered": 2016, - "total": 2268, - "percentage": 88.88 + "covered": 2118, + "total": 2419, + "percentage": 87.55 } }, "src/vs/platform/agentHost/node/agentHostStorageService.ts": { @@ -5037,9 +6599,9 @@ "percentage": 83.43 }, "branches": { - "covered": 24, - "total": 31, - "percentage": 77.41 + "covered": 25, + "total": 32, + "percentage": 78.12 }, "functions": { "covered": 13, @@ -5120,58 +6682,58 @@ }, "src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts": { "statements": { - "covered": 1443, - "total": 1616, - "percentage": 89.29 + "covered": 1620, + "total": 1809, + "percentage": 89.55 }, "branches": { - "covered": 88, - "total": 122, - "percentage": 72.13 + "covered": 103, + "total": 139, + "percentage": 74.1 }, "functions": { - "covered": 18, - "total": 25, - "percentage": 72 + "covered": 21, + "total": 29, + "percentage": 72.41 }, "lines": { - "covered": 1443, - "total": 1616, - "percentage": 89.29 + "covered": 1620, + "total": 1809, + "percentage": 89.55 } }, "src/vs/platform/agentHost/node/agentHostTelemetryService.ts": { "statements": { - "covered": 212, - "total": 295, - "percentage": 71.86 + "covered": 231, + "total": 314, + "percentage": 73.56 }, "branches": { - "covered": 22, - "total": 53, - "percentage": 41.5 + "covered": 27, + "total": 58, + "percentage": 46.55 }, "functions": { - "covered": 17, - "total": 30, - "percentage": 56.66 + "covered": 19, + "total": 32, + "percentage": 59.37 }, "lines": { - "covered": 212, - "total": 295, - "percentage": 71.86 + "covered": 231, + "total": 314, + "percentage": 73.56 } }, "src/vs/platform/agentHost/node/agentHostTerminalManager.ts": { "statements": { - "covered": 904, - "total": 987, - "percentage": 91.59 + "covered": 907, + "total": 992, + "percentage": 91.43 }, "branches": { - "covered": 125, - "total": 157, - "percentage": 79.61 + "covered": 129, + "total": 159, + "percentage": 81.13 }, "functions": { "covered": 36, @@ -5179,21 +6741,21 @@ "percentage": 87.8 }, "lines": { - "covered": 904, - "total": 987, - "percentage": 91.59 + "covered": 907, + "total": 992, + "percentage": 91.43 } }, "src/vs/platform/agentHost/node/agentHostToolCallTracker.ts": { "statements": { - "covered": 246, - "total": 301, - "percentage": 81.72 + "covered": 258, + "total": 317, + "percentage": 81.38 }, "branches": { - "covered": 45, - "total": 51, - "percentage": 88.23 + "covered": 46, + "total": 52, + "percentage": 88.46 }, "functions": { "covered": 13, @@ -5201,9 +6763,9 @@ "percentage": 92.85 }, "lines": { - "covered": 246, - "total": 301, - "percentage": 81.72 + "covered": 258, + "total": 317, + "percentage": 81.38 } }, "src/vs/platform/agentHost/node/agentHostTurnService.ts": { @@ -5274,24 +6836,24 @@ }, "src/vs/platform/agentHost/node/agentHostTurnTracker.ts": { "statements": { - "covered": 726, - "total": 882, - "percentage": 82.31 + "covered": 812, + "total": 974, + "percentage": 83.36 }, "branches": { - "covered": 85, - "total": 114, - "percentage": 74.56 + "covered": 103, + "total": 135, + "percentage": 76.29 }, "functions": { - "covered": 35, - "total": 42, - "percentage": 83.33 + "covered": 43, + "total": 50, + "percentage": 86 }, "lines": { - "covered": 726, - "total": 882, - "percentage": 82.31 + "covered": 812, + "total": 974, + "percentage": 83.36 } }, "src/vs/platform/agentHost/node/agentHostUpgradeChannel.ts": { @@ -5384,31 +6946,31 @@ }, "src/vs/platform/agentHost/node/agentMergeController.ts": { "statements": { - "covered": 507, - "total": 1679, - "percentage": 30.19 + "covered": 517, + "total": 1681, + "percentage": 30.75 }, "branches": { - "covered": 47, - "total": 75, - "percentage": 62.66 + "covered": 48, + "total": 80, + "percentage": 60 }, "functions": { "covered": 18, - "total": 73, - "percentage": 24.65 + "total": 72, + "percentage": 25 }, "lines": { - "covered": 507, - "total": 1679, - "percentage": 30.19 + "covered": 517, + "total": 1681, + "percentage": 30.75 } }, "src/vs/platform/agentHost/node/agentMergeTools.ts": { "statements": { - "covered": 96, - "total": 417, - "percentage": 23.02 + "covered": 98, + "total": 429, + "percentage": 22.84 }, "branches": { "covered": 3, @@ -5421,9 +6983,9 @@ "percentage": 9.52 }, "lines": { - "covered": 96, - "total": 417, - "percentage": 23.02 + "covered": 98, + "total": 429, + "percentage": 22.84 } }, "src/vs/platform/agentHost/node/agentModelRefreshScheduler.ts": { @@ -5472,24 +7034,24 @@ }, "src/vs/platform/agentHost/node/agentPluginManager.ts": { "statements": { - "covered": 293, - "total": 348, - "percentage": 84.19 + "covered": 302, + "total": 357, + "percentage": 84.59 }, "branches": { - "covered": 31, - "total": 44, - "percentage": 70.45 + "covered": 33, + "total": 47, + "percentage": 70.21 }, "functions": { - "covered": 18, - "total": 21, - "percentage": 85.71 + "covered": 20, + "total": 22, + "percentage": 90.9 }, "lines": { - "covered": 293, - "total": 348, - "percentage": 84.19 + "covered": 302, + "total": 357, + "percentage": 84.59 } }, "src/vs/platform/agentHost/node/agentSdkDownloadTelemetry.ts": { @@ -5560,36 +7122,36 @@ }, "src/vs/platform/agentHost/node/agentService.ts": { "statements": { - "covered": 6834, - "total": 9560, - "percentage": 71.48 + "covered": 7270, + "total": 10319, + "percentage": 70.45 }, "branches": { - "covered": 1463, - "total": 2108, - "percentage": 69.4 + "covered": 1638, + "total": 2357, + "percentage": 69.49 }, "functions": { - "covered": 308, - "total": 395, - "percentage": 77.97 + "covered": 327, + "total": 423, + "percentage": 77.3 }, "lines": { - "covered": 6834, - "total": 9560, - "percentage": 71.48 + "covered": 7270, + "total": 10319, + "percentage": 70.45 } }, "src/vs/platform/agentHost/node/agentServiceComposition.ts": { "statements": { - "covered": 210, - "total": 250, - "percentage": 84 + "covered": 220, + "total": 260, + "percentage": 84.61 }, "branches": { - "covered": 7, - "total": 13, - "percentage": 53.84 + "covered": 8, + "total": 14, + "percentage": 57.14 }, "functions": { "covered": 5, @@ -5597,31 +7159,31 @@ "percentage": 29.41 }, "lines": { - "covered": 210, - "total": 250, - "percentage": 84 + "covered": 220, + "total": 260, + "percentage": 84.61 } }, "src/vs/platform/agentHost/node/agentServiceFoundation.ts": { "statements": { - "covered": 138, - "total": 151, - "percentage": 91.39 + "covered": 149, + "total": 159, + "percentage": 93.71 }, "branches": { - "covered": 21, - "total": 25, - "percentage": 84 + "covered": 24, + "total": 28, + "percentage": 85.71 }, "functions": { - "covered": 21, - "total": 27, - "percentage": 77.77 + "covered": 22, + "total": 30, + "percentage": 73.33 }, "lines": { - "covered": 138, - "total": 151, - "percentage": 91.39 + "covered": 149, + "total": 159, + "percentage": 93.71 } }, "src/vs/platform/agentHost/node/agentSessionRegistry.ts": { @@ -5648,14 +7210,14 @@ }, "src/vs/platform/agentHost/node/agentSessionResidency.ts": { "statements": { - "covered": 281, + "covered": 283, "total": 308, - "percentage": 91.23 + "percentage": 91.88 }, "branches": { - "covered": 78, - "total": 92, - "percentage": 84.78 + "covered": 86, + "total": 99, + "percentage": 86.86 }, "functions": { "covered": 16, @@ -5663,31 +7225,31 @@ "percentage": 100 }, "lines": { - "covered": 281, + "covered": 283, "total": 308, - "percentage": 91.23 + "percentage": 91.88 } }, "src/vs/platform/agentHost/node/agentSideEffects.ts": { "statements": { - "covered": 1831, - "total": 2157, - "percentage": 84.88 + "covered": 1946, + "total": 2379, + "percentage": 81.79 }, "branches": { - "covered": 341, - "total": 481, - "percentage": 70.89 + "covered": 372, + "total": 524, + "percentage": 70.99 }, "functions": { - "covered": 55, - "total": 60, - "percentage": 91.66 + "covered": 59, + "total": 69, + "percentage": 85.5 }, "lines": { - "covered": 1831, - "total": 2157, - "percentage": 84.88 + "covered": 1946, + "total": 2379, + "percentage": 81.79 } }, "src/vs/platform/agentHost/node/appNodeModules.ts": { @@ -5800,48 +7362,48 @@ "percentage": 73.46 } }, - "src/vs/platform/agentHost/node/chatContributions/artifactTools/artifactToolsContribution.ts": { + "src/vs/platform/agentHost/node/chatContributions/builtInChatContributions.ts": { "statements": { - "covered": 45, - "total": 45, + "covered": 69, + "total": 69, "percentage": 100 }, "branches": { - "covered": 10, - "total": 10, + "covered": 1, + "total": 1, "percentage": 100 }, "functions": { - "covered": 3, - "total": 3, + "covered": 1, + "total": 1, "percentage": 100 }, "lines": { - "covered": 45, - "total": 45, + "covered": 69, + "total": 69, "percentage": 100 } }, - "src/vs/platform/agentHost/node/chatContributions/builtInChatContributions.ts": { + "src/vs/platform/agentHost/node/chatContributions/chatArchive/chatArchiveContribution.ts": { "statements": { - "covered": 57, - "total": 57, - "percentage": 100 + "covered": 41, + "total": 49, + "percentage": 83.67 }, "branches": { - "covered": 1, - "total": 1, - "percentage": 100 + "covered": 2, + "total": 6, + "percentage": 33.33 }, "functions": { - "covered": 1, - "total": 1, + "covered": 2, + "total": 2, "percentage": 100 }, "lines": { - "covered": 57, - "total": 57, - "percentage": 100 + "covered": 41, + "total": 49, + "percentage": 83.67 } }, "src/vs/platform/agentHost/node/chatContributions/chatDraft/chatDraftContribution.ts": { @@ -5856,14 +7418,58 @@ "percentage": 61.53 }, "functions": { - "covered": 3, - "total": 3, + "covered": 3, + "total": 3, + "percentage": 100 + }, + "lines": { + "covered": 71, + "total": 80, + "percentage": 88.75 + } + }, + "src/vs/platform/agentHost/node/chatContributions/chatInput/chatInputContribution.ts": { + "statements": { + "covered": 38, + "total": 42, + "percentage": 90.47 + }, + "branches": { + "covered": 11, + "total": 15, + "percentage": 73.33 + }, + "functions": { + "covered": 2, + "total": 2, + "percentage": 100 + }, + "lines": { + "covered": 38, + "total": 42, + "percentage": 90.47 + } + }, + "src/vs/platform/agentHost/node/chatContributions/chatRead/chatReadContribution.ts": { + "statements": { + "covered": 48, + "total": 48, + "percentage": 100 + }, + "branches": { + "covered": 12, + "total": 12, + "percentage": 100 + }, + "functions": { + "covered": 2, + "total": 2, "percentage": 100 }, "lines": { - "covered": 71, - "total": 80, - "percentage": 88.75 + "covered": 48, + "total": 48, + "percentage": 100 } }, "src/vs/platform/agentHost/node/chatContributions/chatSurface/chatSurfaceContribution.ts": { @@ -5934,23 +7540,23 @@ }, "src/vs/platform/agentHost/node/chatContributions/githubReferences/githubReferencesContribution.ts": { "statements": { - "covered": 32, - "total": 32, + "covered": 51, + "total": 51, "percentage": 100 }, "branches": { - "covered": 3, - "total": 4, - "percentage": 75 + "covered": 11, + "total": 12, + "percentage": 91.66 }, "functions": { - "covered": 2, - "total": 2, + "covered": 3, + "total": 3, "percentage": 100 }, "lines": { - "covered": 32, - "total": 32, + "covered": 51, + "total": 51, "percentage": 100 } }, @@ -5978,14 +7584,14 @@ }, "src/vs/platform/agentHost/node/chatContributions/markUnread/markUnreadContribution.ts": { "statements": { - "covered": 40, - "total": 42, - "percentage": 95.23 + "covered": 45, + "total": 50, + "percentage": 90 }, "branches": { - "covered": 8, - "total": 10, - "percentage": 80 + "covered": 13, + "total": 17, + "percentage": 76.47 }, "functions": { "covered": 2, @@ -5993,9 +7599,9 @@ "percentage": 100 }, "lines": { - "covered": 40, - "total": 42, - "percentage": 95.23 + "covered": 45, + "total": 50, + "percentage": 90 } }, "src/vs/platform/agentHost/node/chatContributions/markdownPlanRichLinks/markdownPlanRichLinksContribution.ts": { @@ -6020,6 +7626,28 @@ "percentage": 67.39 } }, + "src/vs/platform/agentHost/node/chatContributions/persistedFailedTurns/persistedFailedTurnsContribution.ts": { + "statements": { + "covered": 98, + "total": 147, + "percentage": 66.66 + }, + "branches": { + "covered": 23, + "total": 30, + "percentage": 76.66 + }, + "functions": { + "covered": 6, + "total": 7, + "percentage": 85.71 + }, + "lines": { + "covered": 98, + "total": 147, + "percentage": 66.66 + } + }, "src/vs/platform/agentHost/node/chatContributions/persistedTurnUsage/persistedTurnUsageContribution.ts": { "statements": { "covered": 145, @@ -6027,9 +7655,9 @@ "percentage": 87.87 }, "branches": { - "covered": 34, - "total": 45, - "percentage": 75.55 + "covered": 35, + "total": 46, + "percentage": 76.08 }, "functions": { "covered": 4, @@ -6066,35 +7694,57 @@ }, "src/vs/platform/agentHost/node/chatContributions/queueDrain/queueDrainContribution.ts": { "statements": { - "covered": 156, - "total": 207, - "percentage": 75.36 + "covered": 175, + "total": 227, + "percentage": 77.09 }, "branches": { - "covered": 27, - "total": 42, - "percentage": 64.28 + "covered": 42, + "total": 61, + "percentage": 68.85 }, "functions": { - "covered": 8, - "total": 9, - "percentage": 88.88 + "covered": 11, + "total": 11, + "percentage": 100 }, "lines": { - "covered": 156, - "total": 207, - "percentage": 75.36 + "covered": 175, + "total": 227, + "percentage": 77.09 + } + }, + "src/vs/platform/agentHost/node/chatContributions/remoteSessionOrigin/remoteSessionOriginContribution.ts": { + "statements": { + "covered": 37, + "total": 38, + "percentage": 97.36 + }, + "branches": { + "covered": 2, + "total": 4, + "percentage": 50 + }, + "functions": { + "covered": 2, + "total": 2, + "percentage": 100 + }, + "lines": { + "covered": 37, + "total": 38, + "percentage": 97.36 } }, "src/vs/platform/agentHost/node/chatContributions/sessionFlags/sessionFlagsContribution.ts": { "statements": { - "covered": 56, - "total": 56, + "covered": 67, + "total": 67, "percentage": 100 }, "branches": { - "covered": 11, - "total": 11, + "covered": 20, + "total": 20, "percentage": 100 }, "functions": { @@ -6103,8 +7753,8 @@ "percentage": 100 }, "lines": { - "covered": 56, - "total": 56, + "covered": 67, + "total": 67, "percentage": 100 } }, @@ -6137,9 +7787,9 @@ "percentage": 92.59 }, "branches": { - "covered": 28, - "total": 35, - "percentage": 80 + "covered": 29, + "total": 37, + "percentage": 78.37 }, "functions": { "covered": 7, @@ -6154,9 +7804,9 @@ }, "src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionContribution.ts": { "statements": { - "covered": 47, - "total": 94, - "percentage": 50 + "covered": 48, + "total": 95, + "percentage": 50.52 }, "branches": { "covered": 8, @@ -6169,16 +7819,16 @@ "percentage": 100 }, "lines": { - "covered": 47, - "total": 94, - "percentage": 50 + "covered": 48, + "total": 95, + "percentage": 50.52 } }, "src/vs/platform/agentHost/node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.ts": { "statements": { - "covered": 149, - "total": 640, - "percentage": 23.28 + "covered": 150, + "total": 642, + "percentage": 23.36 }, "branches": { "covered": 6, @@ -6191,9 +7841,9 @@ "percentage": 22.72 }, "lines": { - "covered": 149, - "total": 640, - "percentage": 23.28 + "covered": 150, + "total": 642, + "percentage": 23.36 } }, "src/vs/platform/agentHost/node/chatContributions/sideChat/sideChatContext.ts": { @@ -6242,13 +7892,13 @@ }, "src/vs/platform/agentHost/node/chatContributions/turnAdmission/turnAdmissionContribution.ts": { "statements": { - "covered": 42, - "total": 48, - "percentage": 87.5 + "covered": 35, + "total": 42, + "percentage": 83.33 }, "branches": { - "covered": 2, - "total": 4, + "covered": 3, + "total": 6, "percentage": 50 }, "functions": { @@ -6257,9 +7907,9 @@ "percentage": 100 }, "lines": { - "covered": 42, - "total": 48, - "percentage": 87.5 + "covered": 35, + "total": 42, + "percentage": 83.33 } }, "src/vs/platform/agentHost/node/chatContributions/turnDelegation/turnDelegationContribution.ts": { @@ -6284,32 +7934,76 @@ "percentage": 87.62 } }, + "src/vs/platform/agentHost/node/chatContributions/workspaceContext/workspaceContextContribution.ts": { + "statements": { + "covered": 293, + "total": 523, + "percentage": 56.02 + }, + "branches": { + "covered": 26, + "total": 49, + "percentage": 53.06 + }, + "functions": { + "covered": 13, + "total": 23, + "percentage": 56.52 + }, + "lines": { + "covered": 293, + "total": 523, + "percentage": 56.02 + } + }, "src/vs/platform/agentHost/node/chatContributions/worktreeAnnouncement/worktreeAnnouncementContribution.ts": { "statements": { - "covered": 31, - "total": 31, - "percentage": 100 + "covered": 60, + "total": 82, + "percentage": 73.17 }, "branches": { - "covered": 5, - "total": 5, + "covered": 13, + "total": 16, + "percentage": 81.25 + }, + "functions": { + "covered": 4, + "total": 4, + "percentage": 100 + }, + "lines": { + "covered": 60, + "total": 82, + "percentage": 73.17 + } + }, + "src/vs/platform/agentHost/node/chatContributions/worktreeFailure/worktreeFailureContribution.ts": { + "statements": { + "covered": 28, + "total": 28, "percentage": 100 }, + "branches": { + "covered": 2, + "total": 3, + "percentage": 66.66 + }, "functions": { "covered": 2, "total": 2, "percentage": 100 }, "lines": { - "covered": 31, - "total": 31, + "covered": 28, + "total": 28, "percentage": 100 } }, "src/vs/platform/agentHost/node/claude/anthropicBetas.ts": { "statements": { - "covered": 40, - "total": 40, + "covered": 36, + "total": 36, "percentage": 100 }, "branches": { @@ -6323,8 +8017,8 @@ "percentage": 100 }, "lines": { - "covered": 40, - "total": 40, + "covered": 36, + "total": 36, "percentage": 100 } }, @@ -6352,68 +8046,68 @@ }, "src/vs/platform/agentHost/node/claude/claudeAgent.ts": { "statements": { - "covered": 2215, - "total": 2665, - "percentage": 83.11 + "covered": 2324, + "total": 2719, + "percentage": 85.47 }, "branches": { - "covered": 226, - "total": 325, - "percentage": 69.53 + "covered": 249, + "total": 371, + "percentage": 67.11 }, "functions": { - "covered": 100, - "total": 128, - "percentage": 78.12 + "covered": 104, + "total": 130, + "percentage": 80 }, "lines": { - "covered": 2215, - "total": 2665, - "percentage": 83.11 + "covered": 2324, + "total": 2719, + "percentage": 85.47 } }, "src/vs/platform/agentHost/node/claude/claudeAgentSdkService.ts": { "statements": { - "covered": 284, - "total": 328, - "percentage": 86.58 + "covered": 291, + "total": 334, + "percentage": 87.12 }, "branches": { - "covered": 15, - "total": 21, - "percentage": 71.42 + "covered": 17, + "total": 24, + "percentage": 70.83 }, "functions": { - "covered": 13, + "covered": 14, "total": 16, - "percentage": 81.25 + "percentage": 87.5 }, "lines": { - "covered": 284, - "total": 328, - "percentage": 86.58 + "covered": 291, + "total": 334, + "percentage": 87.12 } }, "src/vs/platform/agentHost/node/claude/claudeAgentSession.ts": { "statements": { - "covered": 1357, - "total": 1658, - "percentage": 81.84 + "covered": 1368, + "total": 1670, + "percentage": 81.91 }, "branches": { - "covered": 101, - "total": 159, - "percentage": 63.52 + "covered": 104, + "total": 163, + "percentage": 63.8 }, "functions": { - "covered": 48, - "total": 73, - "percentage": 65.75 + "covered": 49, + "total": 74, + "percentage": 66.21 }, "lines": { - "covered": 1357, - "total": 1658, - "percentage": 81.84 + "covered": 1368, + "total": 1670, + "percentage": 81.91 } }, "src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts": { @@ -6484,14 +8178,14 @@ }, "src/vs/platform/agentHost/node/claude/claudeFileEditObserver.ts": { "statements": { - "covered": 141, - "total": 150, - "percentage": 94 + "covered": 144, + "total": 153, + "percentage": 94.11 }, "branches": { - "covered": 13, - "total": 18, - "percentage": 72.22 + "covered": 16, + "total": 23, + "percentage": 69.56 }, "functions": { "covered": 4, @@ -6499,9 +8193,9 @@ "percentage": 100 }, "lines": { - "covered": 141, - "total": 150, - "percentage": 94 + "covered": 144, + "total": 153, + "percentage": 94.11 } }, "src/vs/platform/agentHost/node/claude/claudeFolderPickerCriteria.ts": { @@ -6550,24 +8244,24 @@ }, "src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts": { "statements": { - "covered": 702, - "total": 782, - "percentage": 89.76 + "covered": 721, + "total": 805, + "percentage": 89.56 }, "branches": { - "covered": 98, - "total": 134, - "percentage": 73.13 - }, - "functions": { - "covered": 23, - "total": 23, - "percentage": 100 + "covered": 101, + "total": 138, + "percentage": 73.18 + }, + "functions": { + "covered": 24, + "total": 25, + "percentage": 96 }, "lines": { - "covered": 702, - "total": 782, - "percentage": 89.76 + "covered": 721, + "total": 805, + "percentage": 89.56 } }, "src/vs/platform/agentHost/node/claude/claudeMcpServerNames.ts": { @@ -6599,9 +8293,9 @@ "percentage": 83.83 }, "branches": { - "covered": 38, - "total": 49, - "percentage": 77.55 + "covered": 39, + "total": 50, + "percentage": 78 }, "functions": { "covered": 11, @@ -6731,9 +8425,9 @@ "percentage": 77.3 }, "branches": { - "covered": 62, - "total": 117, - "percentage": 52.99 + "covered": 74, + "total": 126, + "percentage": 58.73 }, "functions": { "covered": 18, @@ -6748,24 +8442,24 @@ }, "src/vs/platform/agentHost/node/claude/claudeSdkMessageRouter.ts": { "statements": { - "covered": 91, - "total": 95, - "percentage": 95.78 + "covered": 106, + "total": 113, + "percentage": 93.8 }, "branches": { - "covered": 10, - "total": 11, - "percentage": 90.9 + "covered": 13, + "total": 16, + "percentage": 81.25 }, "functions": { - "covered": 2, - "total": 3, - "percentage": 66.66 + "covered": 3, + "total": 4, + "percentage": 75 }, "lines": { - "covered": 91, - "total": 95, - "percentage": 95.78 + "covered": 106, + "total": 113, + "percentage": 93.8 } }, "src/vs/platform/agentHost/node/claude/claudeSdkOptions.ts": { @@ -6792,14 +8486,14 @@ }, "src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts": { "statements": { - "covered": 628, - "total": 739, - "percentage": 84.97 + "covered": 629, + "total": 740, + "percentage": 85 }, "branches": { - "covered": 54, - "total": 76, - "percentage": 71.05 + "covered": 57, + "total": 79, + "percentage": 72.15 }, "functions": { "covered": 23, @@ -6807,9 +8501,9 @@ "percentage": 74.19 }, "lines": { - "covered": 628, - "total": 739, - "percentage": 84.97 + "covered": 629, + "total": 740, + "percentage": 85 } }, "src/vs/platform/agentHost/node/claude/claudeServerToolMcpServer.ts": { @@ -6819,9 +8513,9 @@ "percentage": 100 }, "branches": { - "covered": 12, - "total": 14, - "percentage": 85.71 + "covered": 10, + "total": 13, + "percentage": 76.92 }, "functions": { "covered": 4, @@ -6841,9 +8535,9 @@ "percentage": 86.2 }, "branches": { - "covered": 27, - "total": 42, - "percentage": 64.28 + "covered": 28, + "total": 43, + "percentage": 65.11 }, "functions": { "covered": 9, @@ -6885,9 +8579,9 @@ "percentage": 93.72 }, "branches": { - "covered": 40, - "total": 49, - "percentage": 81.63 + "covered": 38, + "total": 47, + "percentage": 80.85 }, "functions": { "covered": 17, @@ -6902,24 +8596,24 @@ }, "src/vs/platform/agentHost/node/claude/claudeSubagentResolver.ts": { "statements": { - "covered": 212, + "covered": 236, "total": 439, - "percentage": 48.29 + "percentage": 53.75 }, "branches": { - "covered": 8, - "total": 11, - "percentage": 72.72 + "covered": 13, + "total": 20, + "percentage": 65 }, "functions": { - "covered": 8, + "covered": 11, "total": 21, - "percentage": 38.09 + "percentage": 52.38 }, "lines": { - "covered": 212, + "covered": 236, "total": 439, - "percentage": 48.29 + "percentage": 53.75 } }, "src/vs/platform/agentHost/node/claude/claudeSubagentSignals.ts": { @@ -6944,6 +8638,28 @@ "percentage": 91.22 } }, + "src/vs/platform/agentHost/node/claude/claudeTerminalOutput.ts": { + "statements": { + "covered": 105, + "total": 193, + "percentage": 54.4 + }, + "branches": { + "covered": 16, + "total": 33, + "percentage": 48.48 + }, + "functions": { + "covered": 4, + "total": 7, + "percentage": 57.14 + }, + "lines": { + "covered": 105, + "total": 193, + "percentage": 54.4 + } + }, "src/vs/platform/agentHost/node/claude/claudeToolCallRegistry.ts": { "statements": { "covered": 221, @@ -6990,14 +8706,14 @@ }, "src/vs/platform/agentHost/node/claude/claudeToolDisplay.ts": { "statements": { - "covered": 480, - "total": 590, - "percentage": 81.35 + "covered": 469, + "total": 588, + "percentage": 79.76 }, "branches": { - "covered": 76, - "total": 131, - "percentage": 58.01 + "covered": 73, + "total": 130, + "percentage": 56.15 }, "functions": { "covered": 15, @@ -7005,9 +8721,9 @@ "percentage": 88.23 }, "lines": { - "covered": 480, - "total": 590, - "percentage": 81.35 + "covered": 469, + "total": 588, + "percentage": 79.76 } }, "src/vs/platform/agentHost/node/claude/claudeTransportMode.ts": { @@ -7215,9 +8931,9 @@ "percentage": 79.96 }, "branches": { - "covered": 48, - "total": 68, - "percentage": 70.58 + "covered": 49, + "total": 69, + "percentage": 71.01 }, "functions": { "covered": 13, @@ -7343,8 +9059,8 @@ "src/vs/platform/agentHost/node/codex/codexAccountState.ts": { "statements": { "covered": 28, - "total": 61, - "percentage": 45.9 + "total": 53, + "percentage": 52.83 }, "branches": { "covered": 1, @@ -7358,8 +9074,8 @@ }, "lines": { "covered": 28, - "total": 61, - "percentage": 45.9 + "total": 53, + "percentage": 52.83 } }, "src/vs/platform/agentHost/node/codex/codexAccountTelemetry.ts": { @@ -7386,36 +9102,36 @@ }, "src/vs/platform/agentHost/node/codex/codexAgent.ts": { "statements": { - "covered": 5884, - "total": 8538, + "covered": 6058, + "total": 8790, "percentage": 68.91 }, "branches": { - "covered": 912, - "total": 1492, - "percentage": 61.12 + "covered": 1045, + "total": 1624, + "percentage": 64.34 }, "functions": { - "covered": 245, - "total": 317, - "percentage": 77.28 + "covered": 256, + "total": 331, + "percentage": 77.34 }, "lines": { - "covered": 5884, - "total": 8538, + "covered": 6058, + "total": 8790, "percentage": 68.91 } }, "src/vs/platform/agentHost/node/codex/codexAppServerClient.ts": { "statements": { - "covered": 417, - "total": 481, - "percentage": 86.69 + "covered": 418, + "total": 482, + "percentage": 86.72 }, "branches": { - "covered": 42, - "total": 62, - "percentage": 67.74 + "covered": 41, + "total": 61, + "percentage": 67.21 }, "functions": { "covered": 17, @@ -7423,16 +9139,16 @@ "percentage": 89.47 }, "lines": { - "covered": 417, - "total": 481, - "percentage": 86.69 + "covered": 418, + "total": 482, + "percentage": 86.72 } }, "src/vs/platform/agentHost/node/codex/codexChatDiscovery.ts": { "statements": { - "covered": 40, - "total": 120, - "percentage": 33.33 + "covered": 42, + "total": 135, + "percentage": 31.11 }, "branches": { "covered": 0, @@ -7441,13 +9157,13 @@ }, "functions": { "covered": 0, - "total": 5, + "total": 6, "percentage": 0 }, "lines": { - "covered": 40, - "total": 120, - "percentage": 33.33 + "covered": 42, + "total": 135, + "percentage": 31.11 } }, "src/vs/platform/agentHost/node/codex/codexChatHistory.ts": { @@ -7501,9 +9217,9 @@ "percentage": 83.41 }, "branches": { - "covered": 41, - "total": 68, - "percentage": 60.29 + "covered": 45, + "total": 71, + "percentage": 63.38 }, "functions": { "covered": 11, @@ -7540,9 +9256,9 @@ }, "src/vs/platform/agentHost/node/codex/codexElicitationMapper.ts": { "statements": { - "covered": 50, - "total": 181, - "percentage": 27.62 + "covered": 51, + "total": 188, + "percentage": 27.12 }, "branches": { "covered": 0, @@ -7555,9 +9271,9 @@ "percentage": 0 }, "lines": { - "covered": 50, - "total": 181, - "percentage": 27.62 + "covered": 51, + "total": 188, + "percentage": 27.12 } }, "src/vs/platform/agentHost/node/codex/codexFolderPickerCriteria.ts": { @@ -7589,9 +9305,9 @@ "percentage": 92.94 }, "branches": { - "covered": 3, - "total": 7, - "percentage": 42.85 + "covered": 5, + "total": 9, + "percentage": 55.55 }, "functions": { "covered": 2, @@ -7628,46 +9344,46 @@ }, "src/vs/platform/agentHost/node/codex/codexLaunchConfig.ts": { "statements": { - "covered": 147, - "total": 190, - "percentage": 77.36 + "covered": 165, + "total": 201, + "percentage": 82.08 }, "branches": { - "covered": 18, - "total": 39, - "percentage": 46.15 + "covered": 23, + "total": 44, + "percentage": 52.27 }, "functions": { - "covered": 6, - "total": 10, - "percentage": 60 + "covered": 8, + "total": 11, + "percentage": 72.72 }, "lines": { - "covered": 147, - "total": 190, - "percentage": 77.36 + "covered": 165, + "total": 201, + "percentage": 82.08 } }, "src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts": { "statements": { - "covered": 710, - "total": 1308, - "percentage": 54.28 + "covered": 814, + "total": 1349, + "percentage": 60.34 }, "branches": { - "covered": 84, - "total": 135, - "percentage": 62.22 + "covered": 98, + "total": 157, + "percentage": 62.42 }, "functions": { - "covered": 20, - "total": 43, - "percentage": 46.51 + "covered": 24, + "total": 45, + "percentage": 53.33 }, "lines": { - "covered": 710, - "total": 1308, - "percentage": 54.28 + "covered": 814, + "total": 1349, + "percentage": 60.34 } }, "src/vs/platform/agentHost/node/codex/codexMcpServers.ts": { @@ -7804,24 +9520,24 @@ }, "src/vs/platform/agentHost/node/codex/codexReplayMapper.ts": { "statements": { - "covered": 255, - "total": 420, - "percentage": 60.71 + "covered": 292, + "total": 435, + "percentage": 67.12 }, "branches": { - "covered": 20, - "total": 53, - "percentage": 37.73 + "covered": 31, + "total": 66, + "percentage": 46.96 }, "functions": { - "covered": 8, - "total": 15, - "percentage": 53.33 + "covered": 10, + "total": 16, + "percentage": 62.5 }, "lines": { - "covered": 255, - "total": 420, - "percentage": 60.71 + "covered": 292, + "total": 435, + "percentage": 67.12 } }, "src/vs/platform/agentHost/node/codex/codexRolloutMetadata.ts": { @@ -7831,9 +9547,9 @@ "percentage": 78.61 }, "branches": { - "covered": 30, - "total": 36, - "percentage": 83.33 + "covered": 32, + "total": 38, + "percentage": 84.21 }, "functions": { "covered": 5, @@ -7892,14 +9608,14 @@ }, "src/vs/platform/agentHost/node/codex/codexShellCommand.ts": { "statements": { - "covered": 36, - "total": 42, - "percentage": 85.71 + "covered": 38, + "total": 43, + "percentage": 88.37 }, "branches": { "covered": 2, - "total": 6, - "percentage": 33.33 + "total": 7, + "percentage": 28.57 }, "functions": { "covered": 2, @@ -7907,9 +9623,31 @@ "percentage": 100 }, "lines": { - "covered": 36, - "total": 42, - "percentage": 85.71 + "covered": 38, + "total": 43, + "percentage": 88.37 + } + }, + "src/vs/platform/agentHost/node/codex/codexTerminalOutput.ts": { + "statements": { + "covered": 21, + "total": 37, + "percentage": 56.75 + }, + "branches": { + "covered": 1, + "total": 1, + "percentage": 100 + }, + "functions": { + "covered": 1, + "total": 2, + "percentage": 50 + }, + "lines": { + "covered": 21, + "total": 37, + "percentage": 56.75 } }, "src/vs/platform/agentHost/node/codex/codexThreadCoordination.ts": { @@ -7937,8 +9675,8 @@ "src/vs/platform/agentHost/node/codex/codexThreadList.ts": { "statements": { "covered": 39, - "total": 66, - "percentage": 59.09 + "total": 70, + "percentage": 55.71 }, "branches": { "covered": 0, @@ -7952,8 +9690,8 @@ }, "lines": { "covered": 39, - "total": 66, - "percentage": 59.09 + "total": 70, + "percentage": 55.71 } }, "src/vs/platform/agentHost/node/codex/codexUserInputMapper.ts": { @@ -8003,13 +9741,13 @@ "src/vs/platform/agentHost/node/commandAutoApprover.ts": { "statements": { "covered": 565, - "total": 711, - "percentage": 79.46 + "total": 716, + "percentage": 78.91 }, "branches": { - "covered": 29, - "total": 76, - "percentage": 38.15 + "covered": 33, + "total": 80, + "percentage": 41.25 }, "functions": { "covered": 18, @@ -8018,15 +9756,15 @@ }, "lines": { "covered": 565, - "total": 711, - "percentage": 79.46 + "total": 716, + "percentage": 78.91 } }, "src/vs/platform/agentHost/node/copilot/agentHostSandboxEngine.ts": { "statements": { - "covered": 114, - "total": 158, - "percentage": 72.15 + "covered": 117, + "total": 165, + "percentage": 70.9 }, "branches": { "covered": 6, @@ -8035,13 +9773,13 @@ }, "functions": { "covered": 4, - "total": 15, - "percentage": 26.66 + "total": 16, + "percentage": 25 }, "lines": { - "covered": 114, - "total": 158, - "percentage": 72.15 + "covered": 117, + "total": 165, + "percentage": 70.9 } }, "src/vs/platform/agentHost/node/copilot/buildSessionEvents.ts": { @@ -8068,31 +9806,31 @@ }, "src/vs/platform/agentHost/node/copilot/byokLmProxyService.ts": { "statements": { - "covered": 133, - "total": 290, - "percentage": 45.86 + "covered": 169, + "total": 351, + "percentage": 48.14 }, "branches": { - "covered": 0, - "total": 0, + "covered": 1, + "total": 1, "percentage": 100 }, "functions": { - "covered": 0, - "total": 10, - "percentage": 0 + "covered": 1, + "total": 13, + "percentage": 7.69 }, "lines": { - "covered": 133, - "total": 290, - "percentage": 45.86 + "covered": 169, + "total": 351, + "percentage": 48.14 } }, "src/vs/platform/agentHost/node/copilot/byokResponsesTranslation.ts": { "statements": { - "covered": 96, - "total": 525, - "percentage": 18.28 + "covered": 142, + "total": 624, + "percentage": 22.75 }, "branches": { "covered": 0, @@ -8101,69 +9839,69 @@ }, "functions": { "covered": 0, - "total": 20, + "total": 24, "percentage": 0 }, "lines": { - "covered": 96, - "total": 525, - "percentage": 18.28 + "covered": 142, + "total": 624, + "percentage": 22.75 } }, "src/vs/platform/agentHost/node/copilot/copilotAgent.ts": { "statements": { - "covered": 5385, - "total": 7320, - "percentage": 73.56 + "covered": 5771, + "total": 7917, + "percentage": 72.89 }, "branches": { - "covered": 965, - "total": 1417, - "percentage": 68.1 + "covered": 1078, + "total": 1553, + "percentage": 69.41 }, "functions": { - "covered": 301, - "total": 366, - "percentage": 82.24 + "covered": 317, + "total": 406, + "percentage": 78.07 }, "lines": { - "covered": 5385, - "total": 7320, - "percentage": 73.56 + "covered": 5771, + "total": 7917, + "percentage": 72.89 } }, "src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts": { "statements": { - "covered": 5827, - "total": 7843, - "percentage": 74.29 + "covered": 6515, + "total": 9152, + "percentage": 71.18 }, "branches": { - "covered": 1215, - "total": 1742, - "percentage": 69.74 + "covered": 1372, + "total": 1990, + "percentage": 68.94 }, "functions": { - "covered": 261, - "total": 311, - "percentage": 83.92 + "covered": 293, + "total": 366, + "percentage": 80.05 }, "lines": { - "covered": 5827, - "total": 7843, - "percentage": 74.29 + "covered": 6515, + "total": 9152, + "percentage": 71.18 } }, "src/vs/platform/agentHost/node/copilot/copilotAgentStartupConfig.ts": { "statements": { - "covered": 47, - "total": 47, + "covered": 51, + "total": 51, "percentage": 100 }, - "branches": { - "covered": 10, - "total": 12, - "percentage": 83.33 + "branches": { + "covered": 13, + "total": 13, + "percentage": 100 }, "functions": { "covered": 5, @@ -8171,8 +9909,8 @@ "percentage": 100 }, "lines": { - "covered": 47, - "total": 47, + "covered": 51, + "total": 51, "percentage": 100 } }, @@ -8198,16 +9936,38 @@ "percentage": 68.75 } }, + "src/vs/platform/agentHost/node/copilot/copilotChatDiscovery.ts": { + "statements": { + "covered": 287, + "total": 341, + "percentage": 84.16 + }, + "branches": { + "covered": 61, + "total": 83, + "percentage": 73.49 + }, + "functions": { + "covered": 14, + "total": 17, + "percentage": 82.35 + }, + "lines": { + "covered": 287, + "total": 341, + "percentage": 84.16 + } + }, "src/vs/platform/agentHost/node/copilot/copilotCliEnvironment.ts": { "statements": { - "covered": 45, + "covered": 54, "total": 55, - "percentage": 81.81 + "percentage": 98.18 }, "branches": { - "covered": 9, - "total": 13, - "percentage": 69.23 + "covered": 11, + "total": 16, + "percentage": 68.75 }, "functions": { "covered": 1, @@ -8215,31 +9975,75 @@ "percentage": 100 }, "lines": { - "covered": 45, + "covered": 54, "total": 55, - "percentage": 81.81 + "percentage": 98.18 + } + }, + "src/vs/platform/agentHost/node/copilot/copilotCustomizationCommandDisplay.ts": { + "statements": { + "covered": 36, + "total": 554, + "percentage": 6.49 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 20, + "percentage": 0 + }, + "lines": { + "covered": 36, + "total": 554, + "percentage": 6.49 + } + }, + "src/vs/platform/agentHost/node/copilot/copilotExtensionTools.ts": { + "statements": { + "covered": 17, + "total": 46, + "percentage": 36.95 + }, + "branches": { + "covered": 1, + "total": 2, + "percentage": 50 + }, + "functions": { + "covered": 1, + "total": 2, + "percentage": 50 + }, + "lines": { + "covered": 17, + "total": 46, + "percentage": 36.95 } }, "src/vs/platform/agentHost/node/copilot/copilotFailureTelemetry.ts": { "statements": { - "covered": 325, + "covered": 363, "total": 459, - "percentage": 70.8 + "percentage": 79.08 }, "branches": { - "covered": 13, - "total": 24, - "percentage": 54.16 + "covered": 18, + "total": 42, + "percentage": 42.85 }, "functions": { - "covered": 6, + "covered": 10, "total": 14, - "percentage": 42.85 + "percentage": 71.42 }, "lines": { - "covered": 325, + "covered": 363, "total": 459, - "percentage": 70.8 + "percentage": 79.08 } }, "src/vs/platform/agentHost/node/copilot/copilotFusionEventIdentity.ts": { @@ -8264,26 +10068,48 @@ "percentage": 75 } }, - "src/vs/platform/agentHost/node/copilot/copilotFusionProgress.ts": { + "src/vs/platform/agentHost/node/copilot/copilotFusionMessageChunks.ts": { "statements": { - "covered": 94, - "total": 302, - "percentage": 31.12 + "covered": 21, + "total": 34, + "percentage": 61.76 }, "branches": { - "covered": 9, - "total": 15, + "covered": 3, + "total": 5, "percentage": 60 }, "functions": { - "covered": 5, - "total": 12, - "percentage": 41.66 + "covered": 3, + "total": 4, + "percentage": 75 }, "lines": { - "covered": 94, - "total": 302, - "percentage": 31.12 + "covered": 21, + "total": 34, + "percentage": 61.76 + } + }, + "src/vs/platform/agentHost/node/copilot/copilotFusionProgress.ts": { + "statements": { + "covered": 107, + "total": 350, + "percentage": 30.57 + }, + "branches": { + "covered": 10, + "total": 17, + "percentage": 58.82 + }, + "functions": { + "covered": 6, + "total": 15, + "percentage": 40 + }, + "lines": { + "covered": 107, + "total": 350, + "percentage": 30.57 } }, "src/vs/platform/agentHost/node/copilot/copilotFusionReplay.ts": { @@ -8310,36 +10136,36 @@ }, "src/vs/platform/agentHost/node/copilot/copilotGitHubCredentials.ts": { "statements": { - "covered": 109, - "total": 145, - "percentage": 75.17 + "covered": 112, + "total": 150, + "percentage": 74.66 }, "branches": { "covered": 12, - "total": 17, - "percentage": 70.58 + "total": 18, + "percentage": 66.66 }, "functions": { "covered": 11, - "total": 17, - "percentage": 64.7 + "total": 18, + "percentage": 61.11 }, "lines": { - "covered": 109, - "total": 145, - "percentage": 75.17 + "covered": 112, + "total": 150, + "percentage": 74.66 } }, "src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts": { "statements": { - "covered": 303, - "total": 314, - "percentage": 96.49 + "covered": 450, + "total": 461, + "percentage": 97.61 }, "branches": { - "covered": 12, - "total": 18, - "percentage": 66.66 + "covered": 14, + "total": 19, + "percentage": 73.68 }, "functions": { "covered": 3, @@ -8347,9 +10173,9 @@ "percentage": 100 }, "lines": { - "covered": 303, - "total": 314, - "percentage": 96.49 + "covered": 450, + "total": 461, + "percentage": 97.61 } }, "src/vs/platform/agentHost/node/copilot/copilotGitProject.ts": { @@ -8374,77 +10200,165 @@ "percentage": 78.18 } }, + "src/vs/platform/agentHost/node/copilot/copilotMcpToolRoutingCache.ts": { + "statements": { + "covered": 57, + "total": 128, + "percentage": 44.53 + }, + "branches": { + "covered": 1, + "total": 1, + "percentage": 100 + }, + "functions": { + "covered": 1, + "total": 6, + "percentage": 16.66 + }, + "lines": { + "covered": 57, + "total": 128, + "percentage": 44.53 + } + }, "src/vs/platform/agentHost/node/copilot/copilotNonPtyShellTerminals.ts": { "statements": { - "covered": 226, - "total": 279, - "percentage": 81 + "covered": 360, + "total": 421, + "percentage": 85.51 }, "branches": { - "covered": 39, - "total": 55, - "percentage": 70.9 + "covered": 59, + "total": 79, + "percentage": 74.68 }, "functions": { - "covered": 12, - "total": 13, - "percentage": 92.3 + "covered": 20, + "total": 21, + "percentage": 95.23 }, "lines": { - "covered": 226, - "total": 279, - "percentage": 81 + "covered": 360, + "total": 421, + "percentage": 85.51 } }, - "src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts": { + "src/vs/platform/agentHost/node/copilot/copilotPermissionAttribution.ts": { "statements": { - "covered": 485, - "total": 540, - "percentage": 89.81 + "covered": 60, + "total": 69, + "percentage": 86.95 }, "branches": { - "covered": 76, - "total": 117, - "percentage": 64.95 + "covered": 34, + "total": 40, + "percentage": 85 }, "functions": { - "covered": 24, - "total": 26, - "percentage": 92.3 + "covered": 4, + "total": 4, + "percentage": 100 }, "lines": { - "covered": 485, - "total": 540, - "percentage": 89.81 + "covered": 60, + "total": 69, + "percentage": 86.95 } }, - "src/vs/platform/agentHost/node/copilot/copilotSandboxPolicy.ts": { + "src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts": { "statements": { + "covered": 365, + "total": 511, + "percentage": 71.42 + }, + "branches": { + "covered": 47, + "total": 78, + "percentage": 60.25 + }, + "functions": { "covered": 19, - "total": 19, + "total": 25, + "percentage": 76 + }, + "lines": { + "covered": 365, + "total": 511, + "percentage": 71.42 + } + }, + "src/vs/platform/agentHost/node/copilot/copilotRuntimePaths.ts": { + "statements": { + "covered": 64, + "total": 79, + "percentage": 81.01 + }, + "branches": { + "covered": 3, + "total": 9, + "percentage": 33.33 + }, + "functions": { + "covered": 3, + "total": 4, + "percentage": 75 + }, + "lines": { + "covered": 64, + "total": 79, + "percentage": 81.01 + } + }, + "src/vs/platform/agentHost/node/copilot/copilotSandboxDiagnostics.ts": { + "statements": { + "covered": 42, + "total": 74, + "percentage": 56.75 + }, + "branches": { + "covered": 3, + "total": 6, + "percentage": 50 + }, + "functions": { + "covered": 2, + "total": 2, "percentage": 100 }, + "lines": { + "covered": 42, + "total": 74, + "percentage": 56.75 + } + }, + "src/vs/platform/agentHost/node/copilot/copilotSandboxPolicy.ts": { + "statements": { + "covered": 48, + "total": 56, + "percentage": 85.71 + }, "branches": { - "covered": 1, - "total": 9, - "percentage": 11.11 + "covered": 3, + "total": 42, + "percentage": 7.14 }, "functions": { - "covered": 1, - "total": 1, + "covered": 2, + "total": 2, "percentage": 100 }, "lines": { - "covered": 19, - "total": 19, - "percentage": 100 + "covered": 48, + "total": 56, + "percentage": 85.71 } }, "src/vs/platform/agentHost/node/copilot/copilotSandboxPolicyDisplay.ts": { "statements": { - "covered": 40, - "total": 107, - "percentage": 37.38 + "covered": 41, + "total": 103, + "percentage": 39.8 }, "branches": { "covered": 1, @@ -8453,13 +10367,13 @@ }, "functions": { "covered": 1, - "total": 5, - "percentage": 20 + "total": 4, + "percentage": 25 }, "lines": { - "covered": 40, - "total": 107, - "percentage": 37.38 + "covered": 41, + "total": 103, + "percentage": 39.8 } }, "src/vs/platform/agentHost/node/copilot/copilotSdkChatError.ts": { @@ -8528,48 +10442,92 @@ "percentage": 90.32 } }, + "src/vs/platform/agentHost/node/copilot/copilotSessionDiscoveryWatchers.ts": { + "statements": { + "covered": 253, + "total": 313, + "percentage": 80.83 + }, + "branches": { + "covered": 54, + "total": 77, + "percentage": 70.12 + }, + "functions": { + "covered": 20, + "total": 22, + "percentage": 90.9 + }, + "lines": { + "covered": 253, + "total": 313, + "percentage": 80.83 + } + }, + "src/vs/platform/agentHost/node/copilot/copilotSessionExecutionMarker.ts": { + "statements": { + "covered": 86, + "total": 105, + "percentage": 81.9 + }, + "branches": { + "covered": 14, + "total": 22, + "percentage": 63.63 + }, + "functions": { + "covered": 6, + "total": 7, + "percentage": 85.71 + }, + "lines": { + "covered": 86, + "total": 105, + "percentage": 81.9 + } + }, "src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts": { "statements": { - "covered": 822, - "total": 1041, - "percentage": 78.96 + "covered": 915, + "total": 1180, + "percentage": 77.54 }, "branches": { - "covered": 121, - "total": 178, - "percentage": 67.97 + "covered": 127, + "total": 199, + "percentage": 63.81 }, "functions": { - "covered": 45, - "total": 53, - "percentage": 84.9 + "covered": 48, + "total": 56, + "percentage": 85.71 }, "lines": { - "covered": 822, - "total": 1041, - "percentage": 78.96 + "covered": 915, + "total": 1180, + "percentage": 77.54 } }, "src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts": { "statements": { - "covered": 470, - "total": 490, - "percentage": 95.91 + "covered": 513, + "total": 528, + "percentage": 97.15 }, "branches": { - "covered": 101, - "total": 106, - "percentage": 95.28 + "covered": 110, + "total": 114, + "percentage": 96.49 }, "functions": { - "covered": 68, - "total": 69, - "percentage": 98.55 + "covered": 74, + "total": 75, + "percentage": 98.66 }, "lines": { - "covered": 470, - "total": 490, - "percentage": 95.91 + "covered": 513, + "total": 528, + "percentage": 97.15 } }, "src/vs/platform/agentHost/node/copilot/copilotShellTools.ts": { @@ -8596,9 +10554,9 @@ }, "src/vs/platform/agentHost/node/copilot/copilotSlashCommand.ts": { "statements": { - "covered": 28, - "total": 40, - "percentage": 70 + "covered": 35, + "total": 47, + "percentage": 74.46 }, "branches": { "covered": 0, @@ -8611,16 +10569,16 @@ "percentage": 0 }, "lines": { - "covered": 28, - "total": 40, - "percentage": 70 + "covered": 35, + "total": 47, + "percentage": 74.46 } }, "src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts": { "statements": { - "covered": 100, - "total": 271, - "percentage": 36.9 + "covered": 118, + "total": 542, + "percentage": 21.77 }, "branches": { "covered": 2, @@ -8629,20 +10587,20 @@ }, "functions": { "covered": 2, - "total": 8, - "percentage": 25 + "total": 15, + "percentage": 13.33 }, "lines": { - "covered": 100, - "total": 271, - "percentage": 36.9 + "covered": 118, + "total": 542, + "percentage": 21.77 } }, "src/vs/platform/agentHost/node/copilot/copilotSlashCommandProvider.ts": { "statements": { "covered": 51, - "total": 154, - "percentage": 33.11 + "total": 159, + "percentage": 32.07 }, "branches": { "covered": 2, @@ -8656,37 +10614,37 @@ }, "lines": { "covered": 51, - "total": 154, - "percentage": 33.11 + "total": 159, + "percentage": 32.07 } }, "src/vs/platform/agentHost/node/copilot/copilotSystemNotification.ts": { "statements": { - "covered": 19, - "total": 98, - "percentage": 19.38 + "covered": 64, + "total": 152, + "percentage": 42.1 }, "branches": { - "covered": 0, - "total": 0, - "percentage": 100 + "covered": 19, + "total": 23, + "percentage": 82.6 }, "functions": { - "covered": 0, - "total": 2, - "percentage": 0 + "covered": 3, + "total": 5, + "percentage": 60 }, "lines": { - "covered": 19, - "total": 98, - "percentage": 19.38 + "covered": 64, + "total": 152, + "percentage": 42.1 } }, "src/vs/platform/agentHost/node/copilot/copilotTodoStoreTelemetry.ts": { "statements": { - "covered": 170, - "total": 241, - "percentage": 70.53 + "covered": 173, + "total": 244, + "percentage": 70.9 }, "branches": { "covered": 40, @@ -8699,53 +10657,53 @@ "percentage": 77.77 }, "lines": { - "covered": 170, - "total": 241, - "percentage": 70.53 + "covered": 173, + "total": 244, + "percentage": 70.9 } }, "src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts": { "statements": { - "covered": 1063, - "total": 1254, - "percentage": 84.76 + "covered": 1088, + "total": 1292, + "percentage": 84.21 }, "branches": { - "covered": 210, - "total": 328, - "percentage": 64.02 + "covered": 213, + "total": 338, + "percentage": 63.01 }, "functions": { - "covered": 33, - "total": 37, - "percentage": 89.18 + "covered": 34, + "total": 38, + "percentage": 89.47 }, "lines": { - "covered": 1063, - "total": 1254, - "percentage": 84.76 + "covered": 1088, + "total": 1292, + "percentage": 84.21 } }, "src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts": { "statements": { - "covered": 789, - "total": 1094, - "percentage": 72.12 + "covered": 836, + "total": 1165, + "percentage": 71.75 }, "branches": { - "covered": 129, - "total": 223, - "percentage": 57.84 + "covered": 157, + "total": 261, + "percentage": 60.15 }, "functions": { - "covered": 21, - "total": 23, - "percentage": 91.3 + "covered": 23, + "total": 25, + "percentage": 92 }, "lines": { - "covered": 789, - "total": 1094, - "percentage": 72.12 + "covered": 836, + "total": 1165, + "percentage": 71.75 } }, "src/vs/platform/agentHost/node/copilot/modelCallTurnCorrelation.ts": { @@ -8772,8 +10730,8 @@ }, "src/vs/platform/agentHost/node/copilot/modelIdentifiers.ts": { "statements": { - "covered": 19, - "total": 19, + "covered": 22, + "total": 22, "percentage": 100 }, "branches": { @@ -8787,8 +10745,8 @@ "percentage": 100 }, "lines": { - "covered": 19, - "total": 19, + "covered": 22, + "total": 22, "percentage": 100 } }, @@ -8992,14 +10950,14 @@ }, "src/vs/platform/agentHost/node/copilot/sandboxConfigForSdk.ts": { "statements": { - "covered": 171, - "total": 200, - "percentage": 85.5 + "covered": 163, + "total": 194, + "percentage": 84.02 }, "branches": { "covered": 1, - "total": 23, - "percentage": 4.34 + "total": 42, + "percentage": 2.38 }, "functions": { "covered": 1, @@ -9007,21 +10965,21 @@ "percentage": 100 }, "lines": { - "covered": 171, - "total": 200, - "percentage": 85.5 + "covered": 163, + "total": 194, + "percentage": 84.02 } }, "src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts": { "statements": { - "covered": 922, + "covered": 924, "total": 1081, - "percentage": 85.29 + "percentage": 85.47 }, "branches": { - "covered": 186, - "total": 237, - "percentage": 78.48 + "covered": 200, + "total": 250, + "percentage": 80 }, "functions": { "covered": 33, @@ -9029,16 +10987,16 @@ "percentage": 94.28 }, "lines": { - "covered": 922, + "covered": 924, "total": 1081, - "percentage": 85.29 + "percentage": 85.47 } }, "src/vs/platform/agentHost/node/copilot/toolSearchDeferral.ts": { "statements": { - "covered": 36, - "total": 42, - "percentage": 85.71 + "covered": 35, + "total": 41, + "percentage": 85.36 }, "branches": { "covered": 1, @@ -9051,38 +11009,38 @@ "percentage": 100 }, "lines": { - "covered": 36, - "total": 42, - "percentage": 85.71 + "covered": 35, + "total": 41, + "percentage": 85.36 } }, "src/vs/platform/agentHost/node/devContainerAgentHostProtocol.ts": { "statements": { - "covered": 82, - "total": 195, - "percentage": 42.05 + "covered": 86, + "total": 220, + "percentage": 39.09 }, "branches": { "covered": 3, - "total": 8, - "percentage": 37.5 + "total": 10, + "percentage": 30 }, "functions": { "covered": 3, - "total": 12, - "percentage": 25 + "total": 13, + "percentage": 23.07 }, "lines": { - "covered": 82, - "total": 195, - "percentage": 42.05 + "covered": 86, + "total": 220, + "percentage": 39.09 } }, "src/vs/platform/agentHost/node/devContainerAgentHostService.ts": { "statements": { - "covered": 215, - "total": 857, - "percentage": 25.08 + "covered": 254, + "total": 1110, + "percentage": 22.88 }, "branches": { "covered": 2, @@ -9091,25 +11049,69 @@ }, "functions": { "covered": 2, - "total": 42, - "percentage": 4.76 + "total": 54, + "percentage": 3.7 }, "lines": { - "covered": 215, - "total": 857, - "percentage": 25.08 + "covered": 254, + "total": 1110, + "percentage": 22.88 + } + }, + "src/vs/platform/agentHost/node/devContainerSamples.ts": { + "statements": { + "covered": 45, + "total": 189, + "percentage": 23.8 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 8, + "percentage": 0 + }, + "lines": { + "covered": 45, + "total": 189, + "percentage": 23.8 + } + }, + "src/vs/platform/agentHost/node/devContainerServerCache.ts": { + "statements": { + "covered": 35, + "total": 109, + "percentage": 32.11 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 6, + "percentage": 0 + }, + "lines": { + "covered": 35, + "total": 109, + "percentage": 32.11 } }, "src/vs/platform/agentHost/node/diffComputeService.ts": { "statements": { - "covered": 79, + "covered": 81, "total": 102, - "percentage": 77.45 + "percentage": 79.41 }, "branches": { - "covered": 10, + "covered": 11, "total": 15, - "percentage": 66.66 + "percentage": 73.33 }, "functions": { "covered": 6, @@ -9117,9 +11119,9 @@ "percentage": 85.71 }, "lines": { - "covered": 79, + "covered": 81, "total": 102, - "percentage": 77.45 + "percentage": 79.41 } }, "src/vs/platform/agentHost/node/diffWorkerMain.ts": { @@ -9151,9 +11153,9 @@ "percentage": 92.85 }, "branches": { - "covered": 7, - "total": 9, - "percentage": 77.77 + "covered": 8, + "total": 10, + "percentage": 80 }, "functions": { "covered": 2, @@ -9322,53 +11324,75 @@ }, "src/vs/platform/agentHost/node/otel/agentHostOTelService.ts": { "statements": { - "covered": 310, - "total": 656, - "percentage": 47.25 + "covered": 616, + "total": 738, + "percentage": 83.46 }, "branches": { - "covered": 16, - "total": 52, - "percentage": 30.76 + "covered": 130, + "total": 170, + "percentage": 76.47 }, "functions": { - "covered": 16, - "total": 33, - "percentage": 48.48 + "covered": 33, + "total": 38, + "percentage": 86.84 }, "lines": { - "covered": 310, - "total": 656, - "percentage": 47.25 + "covered": 616, + "total": 738, + "percentage": 83.46 } }, "src/vs/platform/agentHost/node/protocolServerHandler.ts": { "statements": { - "covered": 1993, - "total": 2348, - "percentage": 84.88 + "covered": 2052, + "total": 2465, + "percentage": 83.24 + }, + "branches": { + "covered": 447, + "total": 581, + "percentage": 76.93 + }, + "functions": { + "covered": 101, + "total": 113, + "percentage": 89.38 + }, + "lines": { + "covered": 2052, + "total": 2465, + "percentage": 83.24 + } + }, + "src/vs/platform/agentHost/node/remoteAgentHostCliCache.ts": { + "statements": { + "covered": 10, + "total": 74, + "percentage": 13.51 }, "branches": { - "covered": 438, - "total": 565, - "percentage": 77.52 + "covered": 0, + "total": 0, + "percentage": 100 }, "functions": { - "covered": 98, - "total": 105, - "percentage": 93.33 + "covered": 0, + "total": 1, + "percentage": 0 }, "lines": { - "covered": 1993, - "total": 2348, - "percentage": 84.88 + "covered": 10, + "total": 74, + "percentage": 13.51 } }, "src/vs/platform/agentHost/node/remoteAgentHostCliInstaller.ts": { "statements": { - "covered": 39, - "total": 149, - "percentage": 26.17 + "covered": 43, + "total": 175, + "percentage": 24.57 }, "branches": { "covered": 0, @@ -9381,9 +11405,9 @@ "percentage": 0 }, "lines": { - "covered": 39, - "total": 149, - "percentage": 26.17 + "covered": 43, + "total": 175, + "percentage": 24.57 } }, "src/vs/platform/agentHost/node/serverUrls.ts": { @@ -9415,9 +11439,9 @@ "percentage": 80.63 }, "branches": { - "covered": 29, - "total": 34, - "percentage": 85.29 + "covered": 31, + "total": 36, + "percentage": 86.11 }, "functions": { "covered": 14, @@ -9432,58 +11456,58 @@ }, "src/vs/platform/agentHost/node/sessionDatabase.ts": { "statements": { - "covered": 1105, - "total": 1339, - "percentage": 82.52 + "covered": 1144, + "total": 1396, + "percentage": 81.94 }, "branches": { - "covered": 186, - "total": 230, - "percentage": 80.86 + "covered": 220, + "total": 269, + "percentage": 81.78 }, "functions": { - "covered": 62, - "total": 84, - "percentage": 73.8 + "covered": 63, + "total": 86, + "percentage": 73.25 }, "lines": { - "covered": 1105, - "total": 1339, - "percentage": 82.52 + "covered": 1144, + "total": 1396, + "percentage": 81.94 } }, "src/vs/platform/agentHost/node/sessionDiffAggregator.ts": { "statements": { - "covered": 272, - "total": 508, - "percentage": 53.54 + "covered": 415, + "total": 524, + "percentage": 79.19 }, "branches": { - "covered": 22, - "total": 42, - "percentage": 52.38 + "covered": 76, + "total": 101, + "percentage": 75.24 }, "functions": { - "covered": 4, - "total": 6, - "percentage": 66.66 + "covered": 7, + "total": 7, + "percentage": 100 }, "lines": { - "covered": 272, - "total": 508, - "percentage": 53.54 + "covered": 415, + "total": 524, + "percentage": 79.19 } }, "src/vs/platform/agentHost/node/sessionPermissions.ts": { "statements": { - "covered": 584, - "total": 782, - "percentage": 74.68 + "covered": 594, + "total": 778, + "percentage": 76.34 }, "branches": { - "covered": 85, - "total": 137, - "percentage": 62.04 + "covered": 103, + "total": 154, + "percentage": 66.88 }, "functions": { "covered": 26, @@ -9491,31 +11515,31 @@ "percentage": 86.66 }, "lines": { - "covered": 584, - "total": 782, - "percentage": 74.68 + "covered": 594, + "total": 778, + "percentage": 76.34 } }, "src/vs/platform/agentHost/node/sessionSandbox.ts": { "statements": { - "covered": 36, - "total": 38, - "percentage": 94.73 + "covered": 63, + "total": 72, + "percentage": 87.5 }, "branches": { - "covered": 6, - "total": 16, - "percentage": 37.5 + "covered": 21, + "total": 36, + "percentage": 58.33 }, "functions": { - "covered": 1, - "total": 1, + "covered": 2, + "total": 2, "percentage": 100 }, "lines": { - "covered": 36, - "total": 38, - "percentage": 94.73 + "covered": 63, + "total": 72, + "percentage": 87.5 } }, "src/vs/platform/agentHost/node/shared/agentBranchNameGenerator.ts": { @@ -9542,9 +11566,9 @@ }, "src/vs/platform/agentHost/node/shared/agentEditAttributionService.ts": { "statements": { - "covered": 274, - "total": 1414, - "percentage": 19.37 + "covered": 277, + "total": 1426, + "percentage": 19.42 }, "branches": { "covered": 10, @@ -9557,21 +11581,21 @@ "percentage": 16.98 }, "lines": { - "covered": 274, - "total": 1414, - "percentage": 19.37 + "covered": 277, + "total": 1426, + "percentage": 19.42 } }, "src/vs/platform/agentHost/node/shared/agentFeedbackServerTools.ts": { "statements": { - "covered": 621, - "total": 691, - "percentage": 89.86 + "covered": 627, + "total": 697, + "percentage": 89.95 }, "branches": { - "covered": 62, - "total": 100, - "percentage": 62 + "covered": 61, + "total": 99, + "percentage": 61.61 }, "functions": { "covered": 27, @@ -9579,38 +11603,16 @@ "percentage": 96.42 }, "lines": { - "covered": 621, - "total": 691, - "percentage": 89.86 - } - }, - "src/vs/platform/agentHost/node/shared/agentHostOctoKitService.ts": { - "statements": { - "covered": 211, - "total": 516, - "percentage": 40.89 - }, - "branches": { - "covered": 1, - "total": 2, - "percentage": 50 - }, - "functions": { - "covered": 1, - "total": 15, - "percentage": 6.66 - }, - "lines": { - "covered": 211, - "total": 516, - "percentage": 40.89 + "covered": 627, + "total": 697, + "percentage": 89.95 } }, "src/vs/platform/agentHost/node/shared/agentMergeServerTools.ts": { "statements": { - "covered": 131, - "total": 365, - "percentage": 35.89 + "covered": 136, + "total": 381, + "percentage": 35.69 }, "branches": { "covered": 5, @@ -9619,35 +11621,35 @@ }, "functions": { "covered": 3, - "total": 16, - "percentage": 18.75 + "total": 17, + "percentage": 17.64 }, "lines": { - "covered": 131, - "total": 365, - "percentage": 35.89 + "covered": 136, + "total": 381, + "percentage": 35.69 } }, "src/vs/platform/agentHost/node/shared/agentMergeToolRestrictions.ts": { "statements": { - "covered": 19, - "total": 37, - "percentage": 51.35 + "covered": 55, + "total": 73, + "percentage": 75.34 }, "branches": { - "covered": 0, - "total": 0, - "percentage": 100 + "covered": 17, + "total": 20, + "percentage": 85 }, "functions": { - "covered": 0, - "total": 3, - "percentage": 0 + "covered": 3, + "total": 6, + "percentage": 50 }, "lines": { - "covered": 19, - "total": 37, - "percentage": 51.35 + "covered": 55, + "total": 73, + "percentage": 75.34 } }, "src/vs/platform/agentHost/node/shared/agentServerToolHost.ts": { @@ -9696,14 +11698,14 @@ }, "src/vs/platform/agentHost/node/shared/artifactServerTools.ts": { "statements": { - "covered": 222, - "total": 234, - "percentage": 94.87 + "covered": 233, + "total": 274, + "percentage": 85.03 }, "branches": { - "covered": 47, - "total": 62, - "percentage": 75.8 + "covered": 50, + "total": 73, + "percentage": 68.49 }, "functions": { "covered": 8, @@ -9711,43 +11713,65 @@ "percentage": 100 }, "lines": { - "covered": 222, - "total": 234, - "percentage": 94.87 + "covered": 233, + "total": 274, + "percentage": 85.03 } }, "src/vs/platform/agentHost/node/shared/copilotApiService.ts": { "statements": { - "covered": 1009, - "total": 1135, - "percentage": 88.89 + "covered": 1079, + "total": 1215, + "percentage": 88.8 }, "branches": { - "covered": 65, - "total": 100, - "percentage": 65 + "covered": 86, + "total": 125, + "percentage": 68.8 }, "functions": { - "covered": 25, - "total": 27, - "percentage": 92.59 + "covered": 30, + "total": 33, + "percentage": 90.9 + }, + "lines": { + "covered": 1079, + "total": 1215, + "percentage": 88.8 + } + }, + "src/vs/platform/agentHost/node/shared/copilotSkuTelemetry.ts": { + "statements": { + "covered": 20, + "total": 20, + "percentage": 100 + }, + "branches": { + "covered": 7, + "total": 8, + "percentage": 87.5 + }, + "functions": { + "covered": 2, + "total": 2, + "percentage": 100 }, "lines": { - "covered": 1009, - "total": 1135, - "percentage": 88.89 + "covered": 20, + "total": 20, + "percentage": 100 } }, "src/vs/platform/agentHost/node/shared/customizationEnablementGate.ts": { "statements": { - "covered": 171, + "covered": 164, "total": 185, - "percentage": 92.43 + "percentage": 88.64 }, "branches": { - "covered": 59, + "covered": 56, "total": 62, - "percentage": 95.16 + "percentage": 90.32 }, "functions": { "covered": 8, @@ -9755,9 +11779,9 @@ "percentage": 88.88 }, "lines": { - "covered": 171, + "covered": 164, "total": 185, - "percentage": 92.43 + "percentage": 88.64 } }, "src/vs/platform/agentHost/node/shared/editArcReporter.ts": { @@ -9811,9 +11835,9 @@ "percentage": 94.36 }, "branches": { - "covered": 28, - "total": 37, - "percentage": 75.67 + "covered": 27, + "total": 36, + "percentage": 75 }, "functions": { "covered": 5, @@ -9850,9 +11874,9 @@ }, "src/vs/platform/agentHost/node/shared/fileEditTracker.ts": { "statements": { - "covered": 237, - "total": 253, - "percentage": 93.67 + "covered": 238, + "total": 254, + "percentage": 93.7 }, "branches": { "covered": 34, @@ -9865,9 +11889,9 @@ "percentage": 100 }, "lines": { - "covered": 237, - "total": 253, - "percentage": 93.67 + "covered": 238, + "total": 254, + "percentage": 93.7 } }, "src/vs/platform/agentHost/node/shared/fileLinkInstructions.ts": { @@ -9960,24 +11984,24 @@ }, "src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts": { "statements": { - "covered": 592, - "total": 653, - "percentage": 90.65 + "covered": 621, + "total": 703, + "percentage": 88.33 }, "branches": { - "covered": 159, - "total": 194, - "percentage": 81.95 + "covered": 179, + "total": 217, + "percentage": 82.48 }, "functions": { - "covered": 31, - "total": 34, - "percentage": 91.17 + "covered": 32, + "total": 37, + "percentage": 86.48 }, "lines": { - "covered": 592, - "total": 653, - "percentage": 90.65 + "covered": 621, + "total": 703, + "percentage": 88.33 } }, "src/vs/platform/agentHost/node/shared/mcpServerWorkingDirectory.ts": { @@ -10092,14 +12116,14 @@ }, "src/vs/platform/agentHost/node/shared/sessionAdditionalWorktrees.ts": { "statements": { - "covered": 76, - "total": 95, - "percentage": 80 + "covered": 78, + "total": 97, + "percentage": 80.41 }, "branches": { - "covered": 11, - "total": 21, - "percentage": 52.38 + "covered": 12, + "total": 22, + "percentage": 54.54 }, "functions": { "covered": 5, @@ -10107,38 +12131,38 @@ "percentage": 100 }, "lines": { - "covered": 76, - "total": 95, - "percentage": 80 + "covered": 78, + "total": 97, + "percentage": 80.41 } }, "src/vs/platform/agentHost/node/shared/sessionArtifacts.ts": { "statements": { - "covered": 52, - "total": 59, - "percentage": 88.13 + "covered": 79, + "total": 94, + "percentage": 84.04 }, "branches": { - "covered": 6, - "total": 7, - "percentage": 85.71 + "covered": 13, + "total": 17, + "percentage": 76.47 }, "functions": { - "covered": 4, - "total": 5, - "percentage": 80 + "covered": 5, + "total": 6, + "percentage": 83.33 }, "lines": { - "covered": 52, - "total": 59, - "percentage": 88.13 + "covered": 79, + "total": 94, + "percentage": 84.04 } }, "src/vs/platform/agentHost/node/shared/sessionMcpDiscovery.ts": { "statements": { - "covered": 162, - "total": 200, - "percentage": 81 + "covered": 180, + "total": 222, + "percentage": 81.08 }, "branches": { "covered": 29, @@ -10147,35 +12171,35 @@ }, "functions": { "covered": 10, - "total": 10, - "percentage": 100 + "total": 12, + "percentage": 83.33 }, "lines": { - "covered": 162, - "total": 200, - "percentage": 81 + "covered": 180, + "total": 222, + "percentage": 81.08 } }, "src/vs/platform/agentHost/node/shared/sessionServerTools.ts": { "statements": { - "covered": 1352, - "total": 1659, - "percentage": 81.49 + "covered": 1473, + "total": 1934, + "percentage": 76.16 }, "branches": { - "covered": 237, - "total": 399, - "percentage": 59.39 + "covered": 252, + "total": 427, + "percentage": 59.01 }, "functions": { - "covered": 60, - "total": 69, - "percentage": 86.95 + "covered": 59, + "total": 72, + "percentage": 81.94 }, "lines": { - "covered": 1352, - "total": 1659, - "percentage": 81.49 + "covered": 1473, + "total": 1934, + "percentage": 76.16 } }, "src/vs/platform/agentHost/node/shared/shellCommandExecution.ts": { @@ -10246,31 +12270,31 @@ }, "src/vs/platform/agentHost/node/shared/worktreeIsolation.ts": { "statements": { - "covered": 1254, - "total": 1644, - "percentage": 76.27 + "covered": 1338, + "total": 1789, + "percentage": 74.79 }, "branches": { - "covered": 174, - "total": 251, - "percentage": 69.32 + "covered": 179, + "total": 271, + "percentage": 66.05 }, "functions": { - "covered": 49, - "total": 99, - "percentage": 49.49 + "covered": 51, + "total": 104, + "percentage": 49.03 }, "lines": { - "covered": 1254, - "total": 1644, - "percentage": 76.27 + "covered": 1338, + "total": 1789, + "percentage": 74.79 } }, "src/vs/platform/agentHost/node/sshRemoteAgentHostHelpers.ts": { "statements": { - "covered": 304, - "total": 564, - "percentage": 53.9 + "covered": 305, + "total": 565, + "percentage": 53.98 }, "branches": { "covered": 0, @@ -10283,9 +12307,9 @@ "percentage": 0 }, "lines": { - "covered": 304, - "total": 564, - "percentage": 53.9 + "covered": 305, + "total": 565, + "percentage": 53.98 } }, "src/vs/platform/agentHost/node/webSocketTransport.ts": { @@ -10331,6 +12355,28 @@ "total": 21, "percentage": 76.19 } + }, + "src/vs/platform/agentHost/node/worktreeSymlink.ts": { + "statements": { + "covered": 11, + "total": 63, + "percentage": 17.46 + }, + "branches": { + "covered": 0, + "total": 0, + "percentage": 100 + }, + "functions": { + "covered": 0, + "total": 2, + "percentage": 0 + }, + "lines": { + "covered": 11, + "total": 63, + "percentage": 17.46 + } } } } diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__client_tool_reaches_ready_after_start_and_completes.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__client_tool_reaches_ready_after_start_and_completes.traffic.ahp.yaml index 8fed2a72290840..5fcbe3ae5f27ab 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__client_tool_reaches_ready_after_start_and_completes.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__client_tool_reaches_ready_after_start_and_completes.traffic.ahp.yaml @@ -64,6 +64,9 @@ rounds: - channel: ${session_0} action: type: session/ready + - channel: ${session_0} + action: + type: session/serverToolsChanged - channel: ${session_0} action: type: session/changesetsChanged diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-haiku-4_5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-haiku-4_5.prompt.md index 51b968e02e1d7e..bac9ef7a64a7db 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-haiku-4_5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-haiku-4_5.prompt.md @@ -768,6 +768,27 @@ "properties": {} } }, + { + "name": "set_workspace", + "description": "Set the current chat's workspace while preserving its identity and history. If a workspace is already attached, use this tool only when the user explicitly asks to change this chat's workspace. Do not infer a workspace change from a file path, a request to inspect another repository, or ordinary work in the current workspace. If `isolation` is false and the requested folder is already this chat's effective working directory, skip this tool and the confirmation question; tell the user the chat already uses that workspace and continue normally. Compare against this chat's working directory, not its session's project root. Creating a new worktree from the same folder is a real change, not a no-op. For a workspace-less quick chat, attach a workspace only to modify its files or run commands requiring its project environment; not for self-contained scratch work on attachments, pasted/generated content, or throwaway/exportable artifacts. Other chats keep their workspaces. Set `isolation` to true for a new managed Git worktree or false to use the selected folder directly; files are not moved or copied to the selected folder. Immediately before every call, use the available user-input tool to ask one question confirming both workspace and isolation, even if already specified; tool approval is not confirmation. Make this the turn's final tool call and end the turn; the host changes the workspace and continues the original task automatically. Do not repeat the request.", + "input_schema": { + "type": "object", + "properties": { + "workspaceFolder": { + "type": "string", + "description": "Absolute local folder path or file URI to set as the current chat's workspace. Use an exact path from the user or `list_sessions`; do not guess." + }, + "isolation": { + "type": "boolean", + "description": "Whether to create an isolated Git worktree and use it as the workspace. Include this choice in the required user confirmation immediately before calling this tool." + } + }, + "required": [ + "workspaceFolder", + "isolation" + ] + } + }, { "name": "create_session", "description": "Create delegated work and start it with an initial prompt, either in a new chat sharing the current session's workspace, lifecycle, and aggregate diff, or in an independent session. Only supply `worktree` when the user explicitly requests working with or without a new worktree; never combine it with `currentSession`.", @@ -810,30 +831,6 @@ ] } }, - { - "name": "rename_chat", - "description": "Rename one specific chat when the user explicitly asks to rename it. Automatic naming is handled by the host; do not call this tool to name a fresh chat. Renaming the default chat also names its owning session, while peer-chat titles remain independent. Use a short, human-friendly chat name in sentence case (1-4 words). Pass an `agent-host-session://` session or chat link to target another chat, or omit `chat` to rename the chat in which this tool is running. Every invocation replaces the current title.", - "input_schema": { - "type": "object", - "properties": { - "session": { - "type": "string", - "description": "Optional owning session: a session URI from `list_sessions` or an `agent-host-session://` link. When provided with `chat`, it must match that chat's session." - }, - "chat": { - "type": "string", - "description": "The chat to rename: pass an `agent-host-session://` session or chat link. Omit when renaming the chat in which this tool is running." - }, - "title": { - "type": "string", - "description": "Short, descriptive chat title, ideally 1-4 words.\n\n{maxLength: 200}" - } - }, - "required": [ - "title" - ] - } - }, { "name": "send_message", "description": "Send, replace, or cancel your message in an existing session or chat. Use `delivery: \"steer\"` only to update an active turn; steering is handed to the provider immediately and cannot be replaced or cancelled. Use `delivery: \"queue\"` for a separate turn. Replace and cancel require the message ID and revision returned by an earlier call and succeed only while that queued message is pending. If it already started, the result reports `processing` or `completed`; decide whether to send a steering correction or a queued follow-up. Delivery is asynchronous — this tool does not wait for or return a reply.", @@ -922,6 +919,14 @@ "required": [ "session" ] + } + }, + { + "name": "isolate_session", + "description": "Move only the current chat to a new Git worktree when the user wants to continue this task without changing its current checkout. This preserves the chat and its history; it does not create a separate task or move other chats, even those sharing the same folder. The session workspace will include the new worktree. The original folder is unchanged, and uncommitted edits are not copied except configured worktree include-files. Requires a chat working in one local Git folder. Follow the tool confirmation flow. Make this the final tool call and end the turn after it succeeds; the host then moves this chat and continues its task automatically. Do not poll or repeat the request.", + "input_schema": { + "type": "object", + "properties": {} }, "cache_control": { "type": "ephemeral" diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_5.prompt.md index fe0ab29e30585a..3f4170c2f9003a 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_5.prompt.md @@ -768,6 +768,27 @@ "properties": {} } }, + { + "name": "set_workspace", + "description": "Set the current chat's workspace while preserving its identity and history. If a workspace is already attached, use this tool only when the user explicitly asks to change this chat's workspace. Do not infer a workspace change from a file path, a request to inspect another repository, or ordinary work in the current workspace. If `isolation` is false and the requested folder is already this chat's effective working directory, skip this tool and the confirmation question; tell the user the chat already uses that workspace and continue normally. Compare against this chat's working directory, not its session's project root. Creating a new worktree from the same folder is a real change, not a no-op. For a workspace-less quick chat, attach a workspace only to modify its files or run commands requiring its project environment; not for self-contained scratch work on attachments, pasted/generated content, or throwaway/exportable artifacts. Other chats keep their workspaces. Set `isolation` to true for a new managed Git worktree or false to use the selected folder directly; files are not moved or copied to the selected folder. Immediately before every call, use the available user-input tool to ask one question confirming both workspace and isolation, even if already specified; tool approval is not confirmation. Make this the turn's final tool call and end the turn; the host changes the workspace and continues the original task automatically. Do not repeat the request.", + "input_schema": { + "type": "object", + "properties": { + "workspaceFolder": { + "type": "string", + "description": "Absolute local folder path or file URI to set as the current chat's workspace. Use an exact path from the user or `list_sessions`; do not guess." + }, + "isolation": { + "type": "boolean", + "description": "Whether to create an isolated Git worktree and use it as the workspace. Include this choice in the required user confirmation immediately before calling this tool." + } + }, + "required": [ + "workspaceFolder", + "isolation" + ] + } + }, { "name": "create_session", "description": "Create delegated work and start it with an initial prompt, either in a new chat sharing the current session's workspace, lifecycle, and aggregate diff, or in an independent session. Only supply `worktree` when the user explicitly requests working with or without a new worktree; never combine it with `currentSession`.", @@ -810,30 +831,6 @@ ] } }, - { - "name": "rename_chat", - "description": "Rename one specific chat when the user explicitly asks to rename it. Automatic naming is handled by the host; do not call this tool to name a fresh chat. Renaming the default chat also names its owning session, while peer-chat titles remain independent. Use a short, human-friendly chat name in sentence case (1-4 words). Pass an `agent-host-session://` session or chat link to target another chat, or omit `chat` to rename the chat in which this tool is running. Every invocation replaces the current title.", - "input_schema": { - "type": "object", - "properties": { - "session": { - "type": "string", - "description": "Optional owning session: a session URI from `list_sessions` or an `agent-host-session://` link. When provided with `chat`, it must match that chat's session." - }, - "chat": { - "type": "string", - "description": "The chat to rename: pass an `agent-host-session://` session or chat link. Omit when renaming the chat in which this tool is running." - }, - "title": { - "type": "string", - "description": "Short, descriptive chat title, ideally 1-4 words.\n\n{maxLength: 200}" - } - }, - "required": [ - "title" - ] - } - }, { "name": "send_message", "description": "Send, replace, or cancel your message in an existing session or chat. Use `delivery: \"steer\"` only to update an active turn; steering is handed to the provider immediately and cannot be replaced or cancelled. Use `delivery: \"queue\"` for a separate turn. Replace and cancel require the message ID and revision returned by an earlier call and succeed only while that queued message is pending. If it already started, the result reports `processing` or `completed`; decide whether to send a steering correction or a queued follow-up. Delivery is asynchronous — this tool does not wait for or return a reply.", @@ -922,6 +919,14 @@ "required": [ "session" ] + } + }, + { + "name": "isolate_session", + "description": "Move only the current chat to a new Git worktree when the user wants to continue this task without changing its current checkout. This preserves the chat and its history; it does not create a separate task or move other chats, even those sharing the same folder. The session workspace will include the new worktree. The original folder is unchanged, and uncommitted edits are not copied except configured worktree include-files. Requires a chat working in one local Git folder. Follow the tool confirmation flow. Make this the final tool call and end the turn after it succeeds; the host then moves this chat and continues its task automatically. Do not poll or repeat the request.", + "input_schema": { + "type": "object", + "properties": {} }, "cache_control": { "type": "ephemeral" diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_6.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_6.prompt.md index 035a60333311ce..f55ba68c0f109a 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_6.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_6.prompt.md @@ -768,6 +768,27 @@ "properties": {} } }, + { + "name": "set_workspace", + "description": "Set the current chat's workspace while preserving its identity and history. If a workspace is already attached, use this tool only when the user explicitly asks to change this chat's workspace. Do not infer a workspace change from a file path, a request to inspect another repository, or ordinary work in the current workspace. If `isolation` is false and the requested folder is already this chat's effective working directory, skip this tool and the confirmation question; tell the user the chat already uses that workspace and continue normally. Compare against this chat's working directory, not its session's project root. Creating a new worktree from the same folder is a real change, not a no-op. For a workspace-less quick chat, attach a workspace only to modify its files or run commands requiring its project environment; not for self-contained scratch work on attachments, pasted/generated content, or throwaway/exportable artifacts. Other chats keep their workspaces. Set `isolation` to true for a new managed Git worktree or false to use the selected folder directly; files are not moved or copied to the selected folder. Immediately before every call, use the available user-input tool to ask one question confirming both workspace and isolation, even if already specified; tool approval is not confirmation. Make this the turn's final tool call and end the turn; the host changes the workspace and continues the original task automatically. Do not repeat the request.", + "input_schema": { + "type": "object", + "properties": { + "workspaceFolder": { + "type": "string", + "description": "Absolute local folder path or file URI to set as the current chat's workspace. Use an exact path from the user or `list_sessions`; do not guess." + }, + "isolation": { + "type": "boolean", + "description": "Whether to create an isolated Git worktree and use it as the workspace. Include this choice in the required user confirmation immediately before calling this tool." + } + }, + "required": [ + "workspaceFolder", + "isolation" + ] + } + }, { "name": "create_session", "description": "Create delegated work and start it with an initial prompt, either in a new chat sharing the current session's workspace, lifecycle, and aggregate diff, or in an independent session. Only supply `worktree` when the user explicitly requests working with or without a new worktree; never combine it with `currentSession`.", @@ -810,30 +831,6 @@ ] } }, - { - "name": "rename_chat", - "description": "Rename one specific chat when the user explicitly asks to rename it. Automatic naming is handled by the host; do not call this tool to name a fresh chat. Renaming the default chat also names its owning session, while peer-chat titles remain independent. Use a short, human-friendly chat name in sentence case (1-4 words). Pass an `agent-host-session://` session or chat link to target another chat, or omit `chat` to rename the chat in which this tool is running. Every invocation replaces the current title.", - "input_schema": { - "type": "object", - "properties": { - "session": { - "type": "string", - "description": "Optional owning session: a session URI from `list_sessions` or an `agent-host-session://` link. When provided with `chat`, it must match that chat's session." - }, - "chat": { - "type": "string", - "description": "The chat to rename: pass an `agent-host-session://` session or chat link. Omit when renaming the chat in which this tool is running." - }, - "title": { - "type": "string", - "description": "Short, descriptive chat title, ideally 1-4 words.\n\n{maxLength: 200}" - } - }, - "required": [ - "title" - ] - } - }, { "name": "send_message", "description": "Send, replace, or cancel your message in an existing session or chat. Use `delivery: \"steer\"` only to update an active turn; steering is handed to the provider immediately and cannot be replaced or cancelled. Use `delivery: \"queue\"` for a separate turn. Replace and cancel require the message ID and revision returned by an earlier call and succeed only while that queued message is pending. If it already started, the result reports `processing` or `completed`; decide whether to send a steering correction or a queued follow-up. Delivery is asynchronous — this tool does not wait for or return a reply.", @@ -922,6 +919,14 @@ "required": [ "session" ] + } + }, + { + "name": "isolate_session", + "description": "Move only the current chat to a new Git worktree when the user wants to continue this task without changing its current checkout. This preserves the chat and its history; it does not create a separate task or move other chats, even those sharing the same folder. The session workspace will include the new worktree. The original folder is unchanged, and uncommitted edits are not copied except configured worktree include-files. Requires a chat working in one local Git folder. Follow the tool confirmation flow. Make this the final tool call and end the turn after it succeeds; the host then moves this chat and continues its task automatically. Do not poll or repeat the request.", + "input_schema": { + "type": "object", + "properties": {} }, "cache_control": { "type": "ephemeral" diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_7.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_7.prompt.md index 9b9e665afe276e..6d546d595b4bf9 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_7.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_7.prompt.md @@ -768,6 +768,27 @@ "properties": {} } }, + { + "name": "set_workspace", + "description": "Set the current chat's workspace while preserving its identity and history. If a workspace is already attached, use this tool only when the user explicitly asks to change this chat's workspace. Do not infer a workspace change from a file path, a request to inspect another repository, or ordinary work in the current workspace. If `isolation` is false and the requested folder is already this chat's effective working directory, skip this tool and the confirmation question; tell the user the chat already uses that workspace and continue normally. Compare against this chat's working directory, not its session's project root. Creating a new worktree from the same folder is a real change, not a no-op. For a workspace-less quick chat, attach a workspace only to modify its files or run commands requiring its project environment; not for self-contained scratch work on attachments, pasted/generated content, or throwaway/exportable artifacts. Other chats keep their workspaces. Set `isolation` to true for a new managed Git worktree or false to use the selected folder directly; files are not moved or copied to the selected folder. Immediately before every call, use the available user-input tool to ask one question confirming both workspace and isolation, even if already specified; tool approval is not confirmation. Make this the turn's final tool call and end the turn; the host changes the workspace and continues the original task automatically. Do not repeat the request.", + "input_schema": { + "type": "object", + "properties": { + "workspaceFolder": { + "type": "string", + "description": "Absolute local folder path or file URI to set as the current chat's workspace. Use an exact path from the user or `list_sessions`; do not guess." + }, + "isolation": { + "type": "boolean", + "description": "Whether to create an isolated Git worktree and use it as the workspace. Include this choice in the required user confirmation immediately before calling this tool." + } + }, + "required": [ + "workspaceFolder", + "isolation" + ] + } + }, { "name": "create_session", "description": "Create delegated work and start it with an initial prompt, either in a new chat sharing the current session's workspace, lifecycle, and aggregate diff, or in an independent session. Only supply `worktree` when the user explicitly requests working with or without a new worktree; never combine it with `currentSession`.", @@ -810,30 +831,6 @@ ] } }, - { - "name": "rename_chat", - "description": "Rename one specific chat when the user explicitly asks to rename it. Automatic naming is handled by the host; do not call this tool to name a fresh chat. Renaming the default chat also names its owning session, while peer-chat titles remain independent. Use a short, human-friendly chat name in sentence case (1-4 words). Pass an `agent-host-session://` session or chat link to target another chat, or omit `chat` to rename the chat in which this tool is running. Every invocation replaces the current title.", - "input_schema": { - "type": "object", - "properties": { - "session": { - "type": "string", - "description": "Optional owning session: a session URI from `list_sessions` or an `agent-host-session://` link. When provided with `chat`, it must match that chat's session." - }, - "chat": { - "type": "string", - "description": "The chat to rename: pass an `agent-host-session://` session or chat link. Omit when renaming the chat in which this tool is running." - }, - "title": { - "type": "string", - "description": "Short, descriptive chat title, ideally 1-4 words.\n\n{maxLength: 200}" - } - }, - "required": [ - "title" - ] - } - }, { "name": "send_message", "description": "Send, replace, or cancel your message in an existing session or chat. Use `delivery: \"steer\"` only to update an active turn; steering is handed to the provider immediately and cannot be replaced or cancelled. Use `delivery: \"queue\"` for a separate turn. Replace and cancel require the message ID and revision returned by an earlier call and succeed only while that queued message is pending. If it already started, the result reports `processing` or `completed`; decide whether to send a steering correction or a queued follow-up. Delivery is asynchronous — this tool does not wait for or return a reply.", @@ -922,6 +919,14 @@ "required": [ "session" ] + } + }, + { + "name": "isolate_session", + "description": "Move only the current chat to a new Git worktree when the user wants to continue this task without changing its current checkout. This preserves the chat and its history; it does not create a separate task or move other chats, even those sharing the same folder. The session workspace will include the new worktree. The original folder is unchanged, and uncommitted edits are not copied except configured worktree include-files. Requires a chat working in one local Git folder. Follow the tool confirmation flow. Make this the final tool call and end the turn after it succeeds; the host then moves this chat and continues its task automatically. Do not poll or repeat the request.", + "input_schema": { + "type": "object", + "properties": {} }, "cache_control": { "type": "ephemeral" diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5.prompt.md index 4ee567f27b2523..15fab549ae67cc 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5.prompt.md @@ -768,6 +768,27 @@ "properties": {} } }, + { + "name": "set_workspace", + "description": "Set the current chat's workspace while preserving its identity and history. If a workspace is already attached, use this tool only when the user explicitly asks to change this chat's workspace. Do not infer a workspace change from a file path, a request to inspect another repository, or ordinary work in the current workspace. If `isolation` is false and the requested folder is already this chat's effective working directory, skip this tool and the confirmation question; tell the user the chat already uses that workspace and continue normally. Compare against this chat's working directory, not its session's project root. Creating a new worktree from the same folder is a real change, not a no-op. For a workspace-less quick chat, attach a workspace only to modify its files or run commands requiring its project environment; not for self-contained scratch work on attachments, pasted/generated content, or throwaway/exportable artifacts. Other chats keep their workspaces. Set `isolation` to true for a new managed Git worktree or false to use the selected folder directly; files are not moved or copied to the selected folder. Immediately before every call, use the available user-input tool to ask one question confirming both workspace and isolation, even if already specified; tool approval is not confirmation. Make this the turn's final tool call and end the turn; the host changes the workspace and continues the original task automatically. Do not repeat the request.", + "input_schema": { + "type": "object", + "properties": { + "workspaceFolder": { + "type": "string", + "description": "Absolute local folder path or file URI to set as the current chat's workspace. Use an exact path from the user or `list_sessions`; do not guess." + }, + "isolation": { + "type": "boolean", + "description": "Whether to create an isolated Git worktree and use it as the workspace. Include this choice in the required user confirmation immediately before calling this tool." + } + }, + "required": [ + "workspaceFolder", + "isolation" + ] + } + }, { "name": "create_session", "description": "Create delegated work and start it with an initial prompt, either in a new chat sharing the current session's workspace, lifecycle, and aggregate diff, or in an independent session. Only supply `worktree` when the user explicitly requests working with or without a new worktree; never combine it with `currentSession`.", @@ -810,30 +831,6 @@ ] } }, - { - "name": "rename_chat", - "description": "Rename one specific chat when the user explicitly asks to rename it. Automatic naming is handled by the host; do not call this tool to name a fresh chat. Renaming the default chat also names its owning session, while peer-chat titles remain independent. Use a short, human-friendly chat name in sentence case (1-4 words). Pass an `agent-host-session://` session or chat link to target another chat, or omit `chat` to rename the chat in which this tool is running. Every invocation replaces the current title.", - "input_schema": { - "type": "object", - "properties": { - "session": { - "type": "string", - "description": "Optional owning session: a session URI from `list_sessions` or an `agent-host-session://` link. When provided with `chat`, it must match that chat's session." - }, - "chat": { - "type": "string", - "description": "The chat to rename: pass an `agent-host-session://` session or chat link. Omit when renaming the chat in which this tool is running." - }, - "title": { - "type": "string", - "description": "Short, descriptive chat title, ideally 1-4 words.\n\n{maxLength: 200}" - } - }, - "required": [ - "title" - ] - } - }, { "name": "send_message", "description": "Send, replace, or cancel your message in an existing session or chat. Use `delivery: \"steer\"` only to update an active turn; steering is handed to the provider immediately and cannot be replaced or cancelled. Use `delivery: \"queue\"` for a separate turn. Replace and cancel require the message ID and revision returned by an earlier call and succeed only while that queued message is pending. If it already started, the result reports `processing` or `completed`; decide whether to send a steering correction or a queued follow-up. Delivery is asynchronous — this tool does not wait for or return a reply.", @@ -922,6 +919,14 @@ "required": [ "session" ] + } + }, + { + "name": "isolate_session", + "description": "Move only the current chat to a new Git worktree when the user wants to continue this task without changing its current checkout. This preserves the chat and its history; it does not create a separate task or move other chats, even those sharing the same folder. The session workspace will include the new worktree. The original folder is unchanged, and uncommitted edits are not copied except configured worktree include-files. Requires a chat working in one local Git folder. Follow the tool confirmation flow. Make this the final tool call and end the turn after it succeeds; the host then moves this chat and continues its task automatically. Do not poll or repeat the request.", + "input_schema": { + "type": "object", + "properties": {} }, "cache_control": { "type": "ephemeral" diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5_5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5_5.prompt.md index 6c248ea61eaf78..06760e7cca56bc 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5_5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5_5.prompt.md @@ -768,6 +768,27 @@ "properties": {} } }, + { + "name": "set_workspace", + "description": "Set the current chat's workspace while preserving its identity and history. If a workspace is already attached, use this tool only when the user explicitly asks to change this chat's workspace. Do not infer a workspace change from a file path, a request to inspect another repository, or ordinary work in the current workspace. If `isolation` is false and the requested folder is already this chat's effective working directory, skip this tool and the confirmation question; tell the user the chat already uses that workspace and continue normally. Compare against this chat's working directory, not its session's project root. Creating a new worktree from the same folder is a real change, not a no-op. For a workspace-less quick chat, attach a workspace only to modify its files or run commands requiring its project environment; not for self-contained scratch work on attachments, pasted/generated content, or throwaway/exportable artifacts. Other chats keep their workspaces. Set `isolation` to true for a new managed Git worktree or false to use the selected folder directly; files are not moved or copied to the selected folder. Immediately before every call, use the available user-input tool to ask one question confirming both workspace and isolation, even if already specified; tool approval is not confirmation. Make this the turn's final tool call and end the turn; the host changes the workspace and continues the original task automatically. Do not repeat the request.", + "input_schema": { + "type": "object", + "properties": { + "workspaceFolder": { + "type": "string", + "description": "Absolute local folder path or file URI to set as the current chat's workspace. Use an exact path from the user or `list_sessions`; do not guess." + }, + "isolation": { + "type": "boolean", + "description": "Whether to create an isolated Git worktree and use it as the workspace. Include this choice in the required user confirmation immediately before calling this tool." + } + }, + "required": [ + "workspaceFolder", + "isolation" + ] + } + }, { "name": "create_session", "description": "Create delegated work and start it with an initial prompt, either in a new chat sharing the current session's workspace, lifecycle, and aggregate diff, or in an independent session. Only supply `worktree` when the user explicitly requests working with or without a new worktree; never combine it with `currentSession`.", @@ -810,30 +831,6 @@ ] } }, - { - "name": "rename_chat", - "description": "Rename one specific chat when the user explicitly asks to rename it. Automatic naming is handled by the host; do not call this tool to name a fresh chat. Renaming the default chat also names its owning session, while peer-chat titles remain independent. Use a short, human-friendly chat name in sentence case (1-4 words). Pass an `agent-host-session://` session or chat link to target another chat, or omit `chat` to rename the chat in which this tool is running. Every invocation replaces the current title.", - "input_schema": { - "type": "object", - "properties": { - "session": { - "type": "string", - "description": "Optional owning session: a session URI from `list_sessions` or an `agent-host-session://` link. When provided with `chat`, it must match that chat's session." - }, - "chat": { - "type": "string", - "description": "The chat to rename: pass an `agent-host-session://` session or chat link. Omit when renaming the chat in which this tool is running." - }, - "title": { - "type": "string", - "description": "Short, descriptive chat title, ideally 1-4 words.\n\n{maxLength: 200}" - } - }, - "required": [ - "title" - ] - } - }, { "name": "send_message", "description": "Send, replace, or cancel your message in an existing session or chat. Use `delivery: \"steer\"` only to update an active turn; steering is handed to the provider immediately and cannot be replaced or cancelled. Use `delivery: \"queue\"` for a separate turn. Replace and cancel require the message ID and revision returned by an earlier call and succeed only while that queued message is pending. If it already started, the result reports `processing` or `completed`; decide whether to send a steering correction or a queued follow-up. Delivery is asynchronous — this tool does not wait for or return a reply.", @@ -922,6 +919,14 @@ "required": [ "session" ] + } + }, + { + "name": "isolate_session", + "description": "Move only the current chat to a new Git worktree when the user wants to continue this task without changing its current checkout. This preserves the chat and its history; it does not create a separate task or move other chats, even those sharing the same folder. The session workspace will include the new worktree. The original folder is unchanged, and uncommitted edits are not copied except configured worktree include-files. Requires a chat working in one local Git folder. Follow the tool confirmation flow. Make this the final tool call and end the turn after it succeeds; the host then moves this chat and continues its task automatically. Do not poll or repeat the request.", + "input_schema": { + "type": "object", + "properties": {} }, "cache_control": { "type": "ephemeral" diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_5.prompt.md index 7cbe0ffee2765d..08b60c651ee110 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_5.prompt.md @@ -768,6 +768,27 @@ "properties": {} } }, + { + "name": "set_workspace", + "description": "Set the current chat's workspace while preserving its identity and history. If a workspace is already attached, use this tool only when the user explicitly asks to change this chat's workspace. Do not infer a workspace change from a file path, a request to inspect another repository, or ordinary work in the current workspace. If `isolation` is false and the requested folder is already this chat's effective working directory, skip this tool and the confirmation question; tell the user the chat already uses that workspace and continue normally. Compare against this chat's working directory, not its session's project root. Creating a new worktree from the same folder is a real change, not a no-op. For a workspace-less quick chat, attach a workspace only to modify its files or run commands requiring its project environment; not for self-contained scratch work on attachments, pasted/generated content, or throwaway/exportable artifacts. Other chats keep their workspaces. Set `isolation` to true for a new managed Git worktree or false to use the selected folder directly; files are not moved or copied to the selected folder. Immediately before every call, use the available user-input tool to ask one question confirming both workspace and isolation, even if already specified; tool approval is not confirmation. Make this the turn's final tool call and end the turn; the host changes the workspace and continues the original task automatically. Do not repeat the request.", + "input_schema": { + "type": "object", + "properties": { + "workspaceFolder": { + "type": "string", + "description": "Absolute local folder path or file URI to set as the current chat's workspace. Use an exact path from the user or `list_sessions`; do not guess." + }, + "isolation": { + "type": "boolean", + "description": "Whether to create an isolated Git worktree and use it as the workspace. Include this choice in the required user confirmation immediately before calling this tool." + } + }, + "required": [ + "workspaceFolder", + "isolation" + ] + } + }, { "name": "create_session", "description": "Create delegated work and start it with an initial prompt, either in a new chat sharing the current session's workspace, lifecycle, and aggregate diff, or in an independent session. Only supply `worktree` when the user explicitly requests working with or without a new worktree; never combine it with `currentSession`.", @@ -810,30 +831,6 @@ ] } }, - { - "name": "rename_chat", - "description": "Rename one specific chat when the user explicitly asks to rename it. Automatic naming is handled by the host; do not call this tool to name a fresh chat. Renaming the default chat also names its owning session, while peer-chat titles remain independent. Use a short, human-friendly chat name in sentence case (1-4 words). Pass an `agent-host-session://` session or chat link to target another chat, or omit `chat` to rename the chat in which this tool is running. Every invocation replaces the current title.", - "input_schema": { - "type": "object", - "properties": { - "session": { - "type": "string", - "description": "Optional owning session: a session URI from `list_sessions` or an `agent-host-session://` link. When provided with `chat`, it must match that chat's session." - }, - "chat": { - "type": "string", - "description": "The chat to rename: pass an `agent-host-session://` session or chat link. Omit when renaming the chat in which this tool is running." - }, - "title": { - "type": "string", - "description": "Short, descriptive chat title, ideally 1-4 words.\n\n{maxLength: 200}" - } - }, - "required": [ - "title" - ] - } - }, { "name": "send_message", "description": "Send, replace, or cancel your message in an existing session or chat. Use `delivery: \"steer\"` only to update an active turn; steering is handed to the provider immediately and cannot be replaced or cancelled. Use `delivery: \"queue\"` for a separate turn. Replace and cancel require the message ID and revision returned by an earlier call and succeed only while that queued message is pending. If it already started, the result reports `processing` or `completed`; decide whether to send a steering correction or a queued follow-up. Delivery is asynchronous — this tool does not wait for or return a reply.", @@ -922,6 +919,14 @@ "required": [ "session" ] + } + }, + { + "name": "isolate_session", + "description": "Move only the current chat to a new Git worktree when the user wants to continue this task without changing its current checkout. This preserves the chat and its history; it does not create a separate task or move other chats, even those sharing the same folder. The session workspace will include the new worktree. The original folder is unchanged, and uncommitted edits are not copied except configured worktree include-files. Requires a chat working in one local Git folder. Follow the tool confirmation flow. Make this the final tool call and end the turn after it succeeds; the host then moves this chat and continues its task automatically. Do not poll or repeat the request.", + "input_schema": { + "type": "object", + "properties": {} }, "cache_control": { "type": "ephemeral" diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_6.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_6.prompt.md index 94ef868348559f..e4df557c375bec 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_6.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_6.prompt.md @@ -768,6 +768,27 @@ "properties": {} } }, + { + "name": "set_workspace", + "description": "Set the current chat's workspace while preserving its identity and history. If a workspace is already attached, use this tool only when the user explicitly asks to change this chat's workspace. Do not infer a workspace change from a file path, a request to inspect another repository, or ordinary work in the current workspace. If `isolation` is false and the requested folder is already this chat's effective working directory, skip this tool and the confirmation question; tell the user the chat already uses that workspace and continue normally. Compare against this chat's working directory, not its session's project root. Creating a new worktree from the same folder is a real change, not a no-op. For a workspace-less quick chat, attach a workspace only to modify its files or run commands requiring its project environment; not for self-contained scratch work on attachments, pasted/generated content, or throwaway/exportable artifacts. Other chats keep their workspaces. Set `isolation` to true for a new managed Git worktree or false to use the selected folder directly; files are not moved or copied to the selected folder. Immediately before every call, use the available user-input tool to ask one question confirming both workspace and isolation, even if already specified; tool approval is not confirmation. Make this the turn's final tool call and end the turn; the host changes the workspace and continues the original task automatically. Do not repeat the request.", + "input_schema": { + "type": "object", + "properties": { + "workspaceFolder": { + "type": "string", + "description": "Absolute local folder path or file URI to set as the current chat's workspace. Use an exact path from the user or `list_sessions`; do not guess." + }, + "isolation": { + "type": "boolean", + "description": "Whether to create an isolated Git worktree and use it as the workspace. Include this choice in the required user confirmation immediately before calling this tool." + } + }, + "required": [ + "workspaceFolder", + "isolation" + ] + } + }, { "name": "create_session", "description": "Create delegated work and start it with an initial prompt, either in a new chat sharing the current session's workspace, lifecycle, and aggregate diff, or in an independent session. Only supply `worktree` when the user explicitly requests working with or without a new worktree; never combine it with `currentSession`.", @@ -810,30 +831,6 @@ ] } }, - { - "name": "rename_chat", - "description": "Rename one specific chat when the user explicitly asks to rename it. Automatic naming is handled by the host; do not call this tool to name a fresh chat. Renaming the default chat also names its owning session, while peer-chat titles remain independent. Use a short, human-friendly chat name in sentence case (1-4 words). Pass an `agent-host-session://` session or chat link to target another chat, or omit `chat` to rename the chat in which this tool is running. Every invocation replaces the current title.", - "input_schema": { - "type": "object", - "properties": { - "session": { - "type": "string", - "description": "Optional owning session: a session URI from `list_sessions` or an `agent-host-session://` link. When provided with `chat`, it must match that chat's session." - }, - "chat": { - "type": "string", - "description": "The chat to rename: pass an `agent-host-session://` session or chat link. Omit when renaming the chat in which this tool is running." - }, - "title": { - "type": "string", - "description": "Short, descriptive chat title, ideally 1-4 words.\n\n{maxLength: 200}" - } - }, - "required": [ - "title" - ] - } - }, { "name": "send_message", "description": "Send, replace, or cancel your message in an existing session or chat. Use `delivery: \"steer\"` only to update an active turn; steering is handed to the provider immediately and cannot be replaced or cancelled. Use `delivery: \"queue\"` for a separate turn. Replace and cancel require the message ID and revision returned by an earlier call and succeed only while that queued message is pending. If it already started, the result reports `processing` or `completed`; decide whether to send a steering correction or a queued follow-up. Delivery is asynchronous — this tool does not wait for or return a reply.", @@ -922,6 +919,14 @@ "required": [ "session" ] + } + }, + { + "name": "isolate_session", + "description": "Move only the current chat to a new Git worktree when the user wants to continue this task without changing its current checkout. This preserves the chat and its history; it does not create a separate task or move other chats, even those sharing the same folder. The session workspace will include the new worktree. The original folder is unchanged, and uncommitted edits are not copied except configured worktree include-files. Requires a chat working in one local Git folder. Follow the tool confirmation flow. Make this the final tool call and end the turn after it succeeds; the host then moves this chat and continues its task automatically. Do not poll or repeat the request.", + "input_schema": { + "type": "object", + "properties": {} }, "cache_control": { "type": "ephemeral" diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-5.prompt.md index b7577fc290b479..2f713e1af6e11c 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-5.prompt.md @@ -768,6 +768,27 @@ "properties": {} } }, + { + "name": "set_workspace", + "description": "Set the current chat's workspace while preserving its identity and history. If a workspace is already attached, use this tool only when the user explicitly asks to change this chat's workspace. Do not infer a workspace change from a file path, a request to inspect another repository, or ordinary work in the current workspace. If `isolation` is false and the requested folder is already this chat's effective working directory, skip this tool and the confirmation question; tell the user the chat already uses that workspace and continue normally. Compare against this chat's working directory, not its session's project root. Creating a new worktree from the same folder is a real change, not a no-op. For a workspace-less quick chat, attach a workspace only to modify its files or run commands requiring its project environment; not for self-contained scratch work on attachments, pasted/generated content, or throwaway/exportable artifacts. Other chats keep their workspaces. Set `isolation` to true for a new managed Git worktree or false to use the selected folder directly; files are not moved or copied to the selected folder. Immediately before every call, use the available user-input tool to ask one question confirming both workspace and isolation, even if already specified; tool approval is not confirmation. Make this the turn's final tool call and end the turn; the host changes the workspace and continues the original task automatically. Do not repeat the request.", + "input_schema": { + "type": "object", + "properties": { + "workspaceFolder": { + "type": "string", + "description": "Absolute local folder path or file URI to set as the current chat's workspace. Use an exact path from the user or `list_sessions`; do not guess." + }, + "isolation": { + "type": "boolean", + "description": "Whether to create an isolated Git worktree and use it as the workspace. Include this choice in the required user confirmation immediately before calling this tool." + } + }, + "required": [ + "workspaceFolder", + "isolation" + ] + } + }, { "name": "create_session", "description": "Create delegated work and start it with an initial prompt, either in a new chat sharing the current session's workspace, lifecycle, and aggregate diff, or in an independent session. Only supply `worktree` when the user explicitly requests working with or without a new worktree; never combine it with `currentSession`.", @@ -810,30 +831,6 @@ ] } }, - { - "name": "rename_chat", - "description": "Rename one specific chat when the user explicitly asks to rename it. Automatic naming is handled by the host; do not call this tool to name a fresh chat. Renaming the default chat also names its owning session, while peer-chat titles remain independent. Use a short, human-friendly chat name in sentence case (1-4 words). Pass an `agent-host-session://` session or chat link to target another chat, or omit `chat` to rename the chat in which this tool is running. Every invocation replaces the current title.", - "input_schema": { - "type": "object", - "properties": { - "session": { - "type": "string", - "description": "Optional owning session: a session URI from `list_sessions` or an `agent-host-session://` link. When provided with `chat`, it must match that chat's session." - }, - "chat": { - "type": "string", - "description": "The chat to rename: pass an `agent-host-session://` session or chat link. Omit when renaming the chat in which this tool is running." - }, - "title": { - "type": "string", - "description": "Short, descriptive chat title, ideally 1-4 words.\n\n{maxLength: 200}" - } - }, - "required": [ - "title" - ] - } - }, { "name": "send_message", "description": "Send, replace, or cancel your message in an existing session or chat. Use `delivery: \"steer\"` only to update an active turn; steering is handed to the provider immediately and cannot be replaced or cancelled. Use `delivery: \"queue\"` for a separate turn. Replace and cancel require the message ID and revision returned by an earlier call and succeed only while that queued message is pending. If it already started, the result reports `processing` or `completed`; decide whether to send a steering correction or a queued follow-up. Delivery is asynchronous — this tool does not wait for or return a reply.", @@ -922,6 +919,14 @@ "required": [ "session" ] + } + }, + { + "name": "isolate_session", + "description": "Move only the current chat to a new Git worktree when the user wants to continue this task without changing its current checkout. This preserves the chat and its history; it does not create a separate task or move other chats, even those sharing the same folder. The session workspace will include the new worktree. The original folder is unchanged, and uncommitted edits are not copied except configured worktree include-files. Requires a chat working in one local Git folder. Follow the tool confirmation flow. Make this the final tool call and end the turn after it succeeds; the host then moves this chat and continues its task automatically. Do not poll or repeat the request.", + "input_schema": { + "type": "object", + "properties": {} }, "cache_control": { "type": "ephemeral" diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gemini-2_0-flash.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gemini-2_0-flash.prompt.md index 145da0c7adfb9e..40ce282c5a5e39 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gemini-2_0-flash.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gemini-2_0-flash.prompt.md @@ -800,6 +800,29 @@ "strict": false, "type": "function" }, + { + "name": "set_workspace", + "description": "Set the current chat's workspace while preserving its identity and history. If a workspace is already attached, use this tool only when the user explicitly asks to change this chat's workspace. Do not infer a workspace change from a file path, a request to inspect another repository, or ordinary work in the current workspace. If `isolation` is false and the requested folder is already this chat's effective working directory, skip this tool and the confirmation question; tell the user the chat already uses that workspace and continue normally. Compare against this chat's working directory, not its session's project root. Creating a new worktree from the same folder is a real change, not a no-op. For a workspace-less quick chat, attach a workspace only to modify its files or run commands requiring its project environment; not for self-contained scratch work on attachments, pasted/generated content, or throwaway/exportable artifacts. Other chats keep their workspaces. Set `isolation` to true for a new managed Git worktree or false to use the selected folder directly; files are not moved or copied to the selected folder. Immediately before every call, use the available user-input tool to ask one question confirming both workspace and isolation, even if already specified; tool approval is not confirmation. Make this the turn's final tool call and end the turn; the host changes the workspace and continues the original task automatically. Do not repeat the request.", + "parameters": { + "type": "object", + "properties": { + "workspaceFolder": { + "type": "string", + "description": "Absolute local folder path or file URI to set as the current chat's workspace. Use an exact path from the user or `list_sessions`; do not guess." + }, + "isolation": { + "type": "boolean", + "description": "Whether to create an isolated Git worktree and use it as the workspace. Include this choice in the required user confirmation immediately before calling this tool." + } + }, + "required": [ + "workspaceFolder", + "isolation" + ] + }, + "strict": false, + "type": "function" + }, { "name": "create_session", "description": "Create delegated work and start it with an initial prompt, either in a new chat sharing the current session's workspace, lifecycle, and aggregate diff, or in an independent session. Only supply `worktree` when the user explicitly requests working with or without a new worktree; never combine it with `currentSession`.", @@ -844,32 +867,6 @@ "strict": false, "type": "function" }, - { - "name": "rename_chat", - "description": "Rename one specific chat when the user explicitly asks to rename it. Automatic naming is handled by the host; do not call this tool to name a fresh chat. Renaming the default chat also names its owning session, while peer-chat titles remain independent. Use a short, human-friendly chat name in sentence case (1-4 words). Pass an `agent-host-session://` session or chat link to target another chat, or omit `chat` to rename the chat in which this tool is running. Every invocation replaces the current title.", - "parameters": { - "type": "object", - "properties": { - "session": { - "type": "string", - "description": "Optional owning session: a session URI from `list_sessions` or an `agent-host-session://` link. When provided with `chat`, it must match that chat's session." - }, - "chat": { - "type": "string", - "description": "The chat to rename: pass an `agent-host-session://` session or chat link. Omit when renaming the chat in which this tool is running." - }, - "title": { - "type": "string", - "description": "Short, descriptive chat title, ideally 1-4 words.\n\n{maxLength: 200}" - } - }, - "required": [ - "title" - ] - }, - "strict": false, - "type": "function" - }, { "name": "send_message", "description": "Send, replace, or cancel your message in an existing session or chat. Use `delivery: \"steer\"` only to update an active turn; steering is handed to the provider immediately and cannot be replaced or cancelled. Use `delivery: \"queue\"` for a separate turn. Replace and cancel require the message ID and revision returned by an earlier call and succeed only while that queued message is pending. If it already started, the result reports `processing` or `completed`; decide whether to send a steering correction or a queued follow-up. Delivery is asynchronous — this tool does not wait for or return a reply.", @@ -965,6 +962,16 @@ }, "strict": false, "type": "function" + }, + { + "name": "isolate_session", + "description": "Move only the current chat to a new Git worktree when the user wants to continue this task without changing its current checkout. This preserves the chat and its history; it does not create a separate task or move other chats, even those sharing the same folder. The session workspace will include the new worktree. The original folder is unchanged, and uncommitted edits are not copied except configured worktree include-files. Requires a chat working in one local Git folder. Follow the tool confirmation flow. Make this the final tool call and end the turn after it succeeds; the host then moves this chat and continues its task automatically. Do not poll or repeat the request.", + "parameters": { + "type": "object", + "properties": {} + }, + "strict": false, + "type": "function" } ], "reasoning": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-codex.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-codex.prompt.md index 9f6b57d183ee40..ca369170ffb9d7 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-codex.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-codex.prompt.md @@ -761,6 +761,29 @@ "strict": false, "type": "function" }, + { + "name": "set_workspace", + "description": "Set the current chat's workspace while preserving its identity and history. If a workspace is already attached, use this tool only when the user explicitly asks to change this chat's workspace. Do not infer a workspace change from a file path, a request to inspect another repository, or ordinary work in the current workspace. If `isolation` is false and the requested folder is already this chat's effective working directory, skip this tool and the confirmation question; tell the user the chat already uses that workspace and continue normally. Compare against this chat's working directory, not its session's project root. Creating a new worktree from the same folder is a real change, not a no-op. For a workspace-less quick chat, attach a workspace only to modify its files or run commands requiring its project environment; not for self-contained scratch work on attachments, pasted/generated content, or throwaway/exportable artifacts. Other chats keep their workspaces. Set `isolation` to true for a new managed Git worktree or false to use the selected folder directly; files are not moved or copied to the selected folder. Immediately before every call, use the available user-input tool to ask one question confirming both workspace and isolation, even if already specified; tool approval is not confirmation. Make this the turn's final tool call and end the turn; the host changes the workspace and continues the original task automatically. Do not repeat the request.", + "parameters": { + "type": "object", + "properties": { + "workspaceFolder": { + "type": "string", + "description": "Absolute local folder path or file URI to set as the current chat's workspace. Use an exact path from the user or `list_sessions`; do not guess." + }, + "isolation": { + "type": "boolean", + "description": "Whether to create an isolated Git worktree and use it as the workspace. Include this choice in the required user confirmation immediately before calling this tool." + } + }, + "required": [ + "workspaceFolder", + "isolation" + ] + }, + "strict": false, + "type": "function" + }, { "name": "create_session", "description": "Create delegated work and start it with an initial prompt, either in a new chat sharing the current session's workspace, lifecycle, and aggregate diff, or in an independent session. Only supply `worktree` when the user explicitly requests working with or without a new worktree; never combine it with `currentSession`.", @@ -805,32 +828,6 @@ "strict": false, "type": "function" }, - { - "name": "rename_chat", - "description": "Rename one specific chat when the user explicitly asks to rename it. Automatic naming is handled by the host; do not call this tool to name a fresh chat. Renaming the default chat also names its owning session, while peer-chat titles remain independent. Use a short, human-friendly chat name in sentence case (1-4 words). Pass an `agent-host-session://` session or chat link to target another chat, or omit `chat` to rename the chat in which this tool is running. Every invocation replaces the current title.", - "parameters": { - "type": "object", - "properties": { - "session": { - "type": "string", - "description": "Optional owning session: a session URI from `list_sessions` or an `agent-host-session://` link. When provided with `chat`, it must match that chat's session." - }, - "chat": { - "type": "string", - "description": "The chat to rename: pass an `agent-host-session://` session or chat link. Omit when renaming the chat in which this tool is running." - }, - "title": { - "type": "string", - "description": "Short, descriptive chat title, ideally 1-4 words.\n\n{maxLength: 200}" - } - }, - "required": [ - "title" - ] - }, - "strict": false, - "type": "function" - }, { "name": "send_message", "description": "Send, replace, or cancel your message in an existing session or chat. Use `delivery: \"steer\"` only to update an active turn; steering is handed to the provider immediately and cannot be replaced or cancelled. Use `delivery: \"queue\"` for a separate turn. Replace and cancel require the message ID and revision returned by an earlier call and succeed only while that queued message is pending. If it already started, the result reports `processing` or `completed`; decide whether to send a steering correction or a queued follow-up. Delivery is asynchronous — this tool does not wait for or return a reply.", @@ -926,6 +923,16 @@ }, "strict": false, "type": "function" + }, + { + "name": "isolate_session", + "description": "Move only the current chat to a new Git worktree when the user wants to continue this task without changing its current checkout. This preserves the chat and its history; it does not create a separate task or move other chats, even those sharing the same folder. The session workspace will include the new worktree. The original folder is unchanged, and uncommitted edits are not copied except configured worktree include-files. Requires a chat working in one local Git folder. Follow the tool confirmation flow. Make this the final tool call and end the turn after it succeeds; the host then moves this chat and continues its task automatically. Do not poll or repeat the request.", + "parameters": { + "type": "object", + "properties": {} + }, + "strict": false, + "type": "function" } ], "reasoning": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-mini.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-mini.prompt.md index 395c89013ca59a..8a11472cf391a1 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-mini.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-mini.prompt.md @@ -800,6 +800,29 @@ "strict": false, "type": "function" }, + { + "name": "set_workspace", + "description": "Set the current chat's workspace while preserving its identity and history. If a workspace is already attached, use this tool only when the user explicitly asks to change this chat's workspace. Do not infer a workspace change from a file path, a request to inspect another repository, or ordinary work in the current workspace. If `isolation` is false and the requested folder is already this chat's effective working directory, skip this tool and the confirmation question; tell the user the chat already uses that workspace and continue normally. Compare against this chat's working directory, not its session's project root. Creating a new worktree from the same folder is a real change, not a no-op. For a workspace-less quick chat, attach a workspace only to modify its files or run commands requiring its project environment; not for self-contained scratch work on attachments, pasted/generated content, or throwaway/exportable artifacts. Other chats keep their workspaces. Set `isolation` to true for a new managed Git worktree or false to use the selected folder directly; files are not moved or copied to the selected folder. Immediately before every call, use the available user-input tool to ask one question confirming both workspace and isolation, even if already specified; tool approval is not confirmation. Make this the turn's final tool call and end the turn; the host changes the workspace and continues the original task automatically. Do not repeat the request.", + "parameters": { + "type": "object", + "properties": { + "workspaceFolder": { + "type": "string", + "description": "Absolute local folder path or file URI to set as the current chat's workspace. Use an exact path from the user or `list_sessions`; do not guess." + }, + "isolation": { + "type": "boolean", + "description": "Whether to create an isolated Git worktree and use it as the workspace. Include this choice in the required user confirmation immediately before calling this tool." + } + }, + "required": [ + "workspaceFolder", + "isolation" + ] + }, + "strict": false, + "type": "function" + }, { "name": "create_session", "description": "Create delegated work and start it with an initial prompt, either in a new chat sharing the current session's workspace, lifecycle, and aggregate diff, or in an independent session. Only supply `worktree` when the user explicitly requests working with or without a new worktree; never combine it with `currentSession`.", @@ -844,32 +867,6 @@ "strict": false, "type": "function" }, - { - "name": "rename_chat", - "description": "Rename one specific chat when the user explicitly asks to rename it. Automatic naming is handled by the host; do not call this tool to name a fresh chat. Renaming the default chat also names its owning session, while peer-chat titles remain independent. Use a short, human-friendly chat name in sentence case (1-4 words). Pass an `agent-host-session://` session or chat link to target another chat, or omit `chat` to rename the chat in which this tool is running. Every invocation replaces the current title.", - "parameters": { - "type": "object", - "properties": { - "session": { - "type": "string", - "description": "Optional owning session: a session URI from `list_sessions` or an `agent-host-session://` link. When provided with `chat`, it must match that chat's session." - }, - "chat": { - "type": "string", - "description": "The chat to rename: pass an `agent-host-session://` session or chat link. Omit when renaming the chat in which this tool is running." - }, - "title": { - "type": "string", - "description": "Short, descriptive chat title, ideally 1-4 words.\n\n{maxLength: 200}" - } - }, - "required": [ - "title" - ] - }, - "strict": false, - "type": "function" - }, { "name": "send_message", "description": "Send, replace, or cancel your message in an existing session or chat. Use `delivery: \"steer\"` only to update an active turn; steering is handed to the provider immediately and cannot be replaced or cancelled. Use `delivery: \"queue\"` for a separate turn. Replace and cancel require the message ID and revision returned by an earlier call and succeed only while that queued message is pending. If it already started, the result reports `processing` or `completed`; decide whether to send a steering correction or a queued follow-up. Delivery is asynchronous — this tool does not wait for or return a reply.", @@ -965,6 +962,16 @@ }, "strict": false, "type": "function" + }, + { + "name": "isolate_session", + "description": "Move only the current chat to a new Git worktree when the user wants to continue this task without changing its current checkout. This preserves the chat and its history; it does not create a separate task or move other chats, even those sharing the same folder. The session workspace will include the new worktree. The original folder is unchanged, and uncommitted edits are not copied except configured worktree include-files. Requires a chat working in one local Git folder. Follow the tool confirmation flow. Make this the final tool call and end the turn after it succeeds; the host then moves this chat and continues its task automatically. Do not poll or repeat the request.", + "parameters": { + "type": "object", + "properties": {} + }, + "strict": false, + "type": "function" } ], "reasoning": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5.prompt.md index e0893455776445..6dd74506076243 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5.prompt.md @@ -800,6 +800,29 @@ "strict": false, "type": "function" }, + { + "name": "set_workspace", + "description": "Set the current chat's workspace while preserving its identity and history. If a workspace is already attached, use this tool only when the user explicitly asks to change this chat's workspace. Do not infer a workspace change from a file path, a request to inspect another repository, or ordinary work in the current workspace. If `isolation` is false and the requested folder is already this chat's effective working directory, skip this tool and the confirmation question; tell the user the chat already uses that workspace and continue normally. Compare against this chat's working directory, not its session's project root. Creating a new worktree from the same folder is a real change, not a no-op. For a workspace-less quick chat, attach a workspace only to modify its files or run commands requiring its project environment; not for self-contained scratch work on attachments, pasted/generated content, or throwaway/exportable artifacts. Other chats keep their workspaces. Set `isolation` to true for a new managed Git worktree or false to use the selected folder directly; files are not moved or copied to the selected folder. Immediately before every call, use the available user-input tool to ask one question confirming both workspace and isolation, even if already specified; tool approval is not confirmation. Make this the turn's final tool call and end the turn; the host changes the workspace and continues the original task automatically. Do not repeat the request.", + "parameters": { + "type": "object", + "properties": { + "workspaceFolder": { + "type": "string", + "description": "Absolute local folder path or file URI to set as the current chat's workspace. Use an exact path from the user or `list_sessions`; do not guess." + }, + "isolation": { + "type": "boolean", + "description": "Whether to create an isolated Git worktree and use it as the workspace. Include this choice in the required user confirmation immediately before calling this tool." + } + }, + "required": [ + "workspaceFolder", + "isolation" + ] + }, + "strict": false, + "type": "function" + }, { "name": "create_session", "description": "Create delegated work and start it with an initial prompt, either in a new chat sharing the current session's workspace, lifecycle, and aggregate diff, or in an independent session. Only supply `worktree` when the user explicitly requests working with or without a new worktree; never combine it with `currentSession`.", @@ -844,32 +867,6 @@ "strict": false, "type": "function" }, - { - "name": "rename_chat", - "description": "Rename one specific chat when the user explicitly asks to rename it. Automatic naming is handled by the host; do not call this tool to name a fresh chat. Renaming the default chat also names its owning session, while peer-chat titles remain independent. Use a short, human-friendly chat name in sentence case (1-4 words). Pass an `agent-host-session://` session or chat link to target another chat, or omit `chat` to rename the chat in which this tool is running. Every invocation replaces the current title.", - "parameters": { - "type": "object", - "properties": { - "session": { - "type": "string", - "description": "Optional owning session: a session URI from `list_sessions` or an `agent-host-session://` link. When provided with `chat`, it must match that chat's session." - }, - "chat": { - "type": "string", - "description": "The chat to rename: pass an `agent-host-session://` session or chat link. Omit when renaming the chat in which this tool is running." - }, - "title": { - "type": "string", - "description": "Short, descriptive chat title, ideally 1-4 words.\n\n{maxLength: 200}" - } - }, - "required": [ - "title" - ] - }, - "strict": false, - "type": "function" - }, { "name": "send_message", "description": "Send, replace, or cancel your message in an existing session or chat. Use `delivery: \"steer\"` only to update an active turn; steering is handed to the provider immediately and cannot be replaced or cancelled. Use `delivery: \"queue\"` for a separate turn. Replace and cancel require the message ID and revision returned by an earlier call and succeed only while that queued message is pending. If it already started, the result reports `processing` or `completed`; decide whether to send a steering correction or a queued follow-up. Delivery is asynchronous — this tool does not wait for or return a reply.", @@ -965,6 +962,16 @@ }, "strict": false, "type": "function" + }, + { + "name": "isolate_session", + "description": "Move only the current chat to a new Git worktree when the user wants to continue this task without changing its current checkout. This preserves the chat and its history; it does not create a separate task or move other chats, even those sharing the same folder. The session workspace will include the new worktree. The original folder is unchanged, and uncommitted edits are not copied except configured worktree include-files. Requires a chat working in one local Git folder. Follow the tool confirmation flow. Make this the final tool call and end the turn after it succeeds; the host then moves this chat and continues its task automatically. Do not poll or repeat the request.", + "parameters": { + "type": "object", + "properties": {} + }, + "strict": false, + "type": "function" } ], "reasoning": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex-mini.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex-mini.prompt.md index 633d147af14e34..c9041ce8ef22c8 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex-mini.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex-mini.prompt.md @@ -761,6 +761,29 @@ "strict": false, "type": "function" }, + { + "name": "set_workspace", + "description": "Set the current chat's workspace while preserving its identity and history. If a workspace is already attached, use this tool only when the user explicitly asks to change this chat's workspace. Do not infer a workspace change from a file path, a request to inspect another repository, or ordinary work in the current workspace. If `isolation` is false and the requested folder is already this chat's effective working directory, skip this tool and the confirmation question; tell the user the chat already uses that workspace and continue normally. Compare against this chat's working directory, not its session's project root. Creating a new worktree from the same folder is a real change, not a no-op. For a workspace-less quick chat, attach a workspace only to modify its files or run commands requiring its project environment; not for self-contained scratch work on attachments, pasted/generated content, or throwaway/exportable artifacts. Other chats keep their workspaces. Set `isolation` to true for a new managed Git worktree or false to use the selected folder directly; files are not moved or copied to the selected folder. Immediately before every call, use the available user-input tool to ask one question confirming both workspace and isolation, even if already specified; tool approval is not confirmation. Make this the turn's final tool call and end the turn; the host changes the workspace and continues the original task automatically. Do not repeat the request.", + "parameters": { + "type": "object", + "properties": { + "workspaceFolder": { + "type": "string", + "description": "Absolute local folder path or file URI to set as the current chat's workspace. Use an exact path from the user or `list_sessions`; do not guess." + }, + "isolation": { + "type": "boolean", + "description": "Whether to create an isolated Git worktree and use it as the workspace. Include this choice in the required user confirmation immediately before calling this tool." + } + }, + "required": [ + "workspaceFolder", + "isolation" + ] + }, + "strict": false, + "type": "function" + }, { "name": "create_session", "description": "Create delegated work and start it with an initial prompt, either in a new chat sharing the current session's workspace, lifecycle, and aggregate diff, or in an independent session. Only supply `worktree` when the user explicitly requests working with or without a new worktree; never combine it with `currentSession`.", @@ -805,32 +828,6 @@ "strict": false, "type": "function" }, - { - "name": "rename_chat", - "description": "Rename one specific chat when the user explicitly asks to rename it. Automatic naming is handled by the host; do not call this tool to name a fresh chat. Renaming the default chat also names its owning session, while peer-chat titles remain independent. Use a short, human-friendly chat name in sentence case (1-4 words). Pass an `agent-host-session://` session or chat link to target another chat, or omit `chat` to rename the chat in which this tool is running. Every invocation replaces the current title.", - "parameters": { - "type": "object", - "properties": { - "session": { - "type": "string", - "description": "Optional owning session: a session URI from `list_sessions` or an `agent-host-session://` link. When provided with `chat`, it must match that chat's session." - }, - "chat": { - "type": "string", - "description": "The chat to rename: pass an `agent-host-session://` session or chat link. Omit when renaming the chat in which this tool is running." - }, - "title": { - "type": "string", - "description": "Short, descriptive chat title, ideally 1-4 words.\n\n{maxLength: 200}" - } - }, - "required": [ - "title" - ] - }, - "strict": false, - "type": "function" - }, { "name": "send_message", "description": "Send, replace, or cancel your message in an existing session or chat. Use `delivery: \"steer\"` only to update an active turn; steering is handed to the provider immediately and cannot be replaced or cancelled. Use `delivery: \"queue\"` for a separate turn. Replace and cancel require the message ID and revision returned by an earlier call and succeed only while that queued message is pending. If it already started, the result reports `processing` or `completed`; decide whether to send a steering correction or a queued follow-up. Delivery is asynchronous — this tool does not wait for or return a reply.", @@ -926,6 +923,16 @@ }, "strict": false, "type": "function" + }, + { + "name": "isolate_session", + "description": "Move only the current chat to a new Git worktree when the user wants to continue this task without changing its current checkout. This preserves the chat and its history; it does not create a separate task or move other chats, even those sharing the same folder. The session workspace will include the new worktree. The original folder is unchanged, and uncommitted edits are not copied except configured worktree include-files. Requires a chat working in one local Git folder. Follow the tool confirmation flow. Make this the final tool call and end the turn after it succeeds; the host then moves this chat and continues its task automatically. Do not poll or repeat the request.", + "parameters": { + "type": "object", + "properties": {} + }, + "strict": false, + "type": "function" } ], "reasoning": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex.prompt.md index 7692ed33178390..8040cb9c814437 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex.prompt.md @@ -761,6 +761,29 @@ "strict": false, "type": "function" }, + { + "name": "set_workspace", + "description": "Set the current chat's workspace while preserving its identity and history. If a workspace is already attached, use this tool only when the user explicitly asks to change this chat's workspace. Do not infer a workspace change from a file path, a request to inspect another repository, or ordinary work in the current workspace. If `isolation` is false and the requested folder is already this chat's effective working directory, skip this tool and the confirmation question; tell the user the chat already uses that workspace and continue normally. Compare against this chat's working directory, not its session's project root. Creating a new worktree from the same folder is a real change, not a no-op. For a workspace-less quick chat, attach a workspace only to modify its files or run commands requiring its project environment; not for self-contained scratch work on attachments, pasted/generated content, or throwaway/exportable artifacts. Other chats keep their workspaces. Set `isolation` to true for a new managed Git worktree or false to use the selected folder directly; files are not moved or copied to the selected folder. Immediately before every call, use the available user-input tool to ask one question confirming both workspace and isolation, even if already specified; tool approval is not confirmation. Make this the turn's final tool call and end the turn; the host changes the workspace and continues the original task automatically. Do not repeat the request.", + "parameters": { + "type": "object", + "properties": { + "workspaceFolder": { + "type": "string", + "description": "Absolute local folder path or file URI to set as the current chat's workspace. Use an exact path from the user or `list_sessions`; do not guess." + }, + "isolation": { + "type": "boolean", + "description": "Whether to create an isolated Git worktree and use it as the workspace. Include this choice in the required user confirmation immediately before calling this tool." + } + }, + "required": [ + "workspaceFolder", + "isolation" + ] + }, + "strict": false, + "type": "function" + }, { "name": "create_session", "description": "Create delegated work and start it with an initial prompt, either in a new chat sharing the current session's workspace, lifecycle, and aggregate diff, or in an independent session. Only supply `worktree` when the user explicitly requests working with or without a new worktree; never combine it with `currentSession`.", @@ -805,32 +828,6 @@ "strict": false, "type": "function" }, - { - "name": "rename_chat", - "description": "Rename one specific chat when the user explicitly asks to rename it. Automatic naming is handled by the host; do not call this tool to name a fresh chat. Renaming the default chat also names its owning session, while peer-chat titles remain independent. Use a short, human-friendly chat name in sentence case (1-4 words). Pass an `agent-host-session://` session or chat link to target another chat, or omit `chat` to rename the chat in which this tool is running. Every invocation replaces the current title.", - "parameters": { - "type": "object", - "properties": { - "session": { - "type": "string", - "description": "Optional owning session: a session URI from `list_sessions` or an `agent-host-session://` link. When provided with `chat`, it must match that chat's session." - }, - "chat": { - "type": "string", - "description": "The chat to rename: pass an `agent-host-session://` session or chat link. Omit when renaming the chat in which this tool is running." - }, - "title": { - "type": "string", - "description": "Short, descriptive chat title, ideally 1-4 words.\n\n{maxLength: 200}" - } - }, - "required": [ - "title" - ] - }, - "strict": false, - "type": "function" - }, { "name": "send_message", "description": "Send, replace, or cancel your message in an existing session or chat. Use `delivery: \"steer\"` only to update an active turn; steering is handed to the provider immediately and cannot be replaced or cancelled. Use `delivery: \"queue\"` for a separate turn. Replace and cancel require the message ID and revision returned by an earlier call and succeed only while that queued message is pending. If it already started, the result reports `processing` or `completed`; decide whether to send a steering correction or a queued follow-up. Delivery is asynchronous — this tool does not wait for or return a reply.", @@ -926,6 +923,16 @@ }, "strict": false, "type": "function" + }, + { + "name": "isolate_session", + "description": "Move only the current chat to a new Git worktree when the user wants to continue this task without changing its current checkout. This preserves the chat and its history; it does not create a separate task or move other chats, even those sharing the same folder. The session workspace will include the new worktree. The original folder is unchanged, and uncommitted edits are not copied except configured worktree include-files. Requires a chat working in one local Git folder. Follow the tool confirmation flow. Make this the final tool call and end the turn after it succeeds; the host then moves this chat and continues its task automatically. Do not poll or repeat the request.", + "parameters": { + "type": "object", + "properties": {} + }, + "strict": false, + "type": "function" } ], "reasoning": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1.prompt.md index 0c0190cf34cdbe..0874484a66515d 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1.prompt.md @@ -800,6 +800,29 @@ "strict": false, "type": "function" }, + { + "name": "set_workspace", + "description": "Set the current chat's workspace while preserving its identity and history. If a workspace is already attached, use this tool only when the user explicitly asks to change this chat's workspace. Do not infer a workspace change from a file path, a request to inspect another repository, or ordinary work in the current workspace. If `isolation` is false and the requested folder is already this chat's effective working directory, skip this tool and the confirmation question; tell the user the chat already uses that workspace and continue normally. Compare against this chat's working directory, not its session's project root. Creating a new worktree from the same folder is a real change, not a no-op. For a workspace-less quick chat, attach a workspace only to modify its files or run commands requiring its project environment; not for self-contained scratch work on attachments, pasted/generated content, or throwaway/exportable artifacts. Other chats keep their workspaces. Set `isolation` to true for a new managed Git worktree or false to use the selected folder directly; files are not moved or copied to the selected folder. Immediately before every call, use the available user-input tool to ask one question confirming both workspace and isolation, even if already specified; tool approval is not confirmation. Make this the turn's final tool call and end the turn; the host changes the workspace and continues the original task automatically. Do not repeat the request.", + "parameters": { + "type": "object", + "properties": { + "workspaceFolder": { + "type": "string", + "description": "Absolute local folder path or file URI to set as the current chat's workspace. Use an exact path from the user or `list_sessions`; do not guess." + }, + "isolation": { + "type": "boolean", + "description": "Whether to create an isolated Git worktree and use it as the workspace. Include this choice in the required user confirmation immediately before calling this tool." + } + }, + "required": [ + "workspaceFolder", + "isolation" + ] + }, + "strict": false, + "type": "function" + }, { "name": "create_session", "description": "Create delegated work and start it with an initial prompt, either in a new chat sharing the current session's workspace, lifecycle, and aggregate diff, or in an independent session. Only supply `worktree` when the user explicitly requests working with or without a new worktree; never combine it with `currentSession`.", @@ -844,32 +867,6 @@ "strict": false, "type": "function" }, - { - "name": "rename_chat", - "description": "Rename one specific chat when the user explicitly asks to rename it. Automatic naming is handled by the host; do not call this tool to name a fresh chat. Renaming the default chat also names its owning session, while peer-chat titles remain independent. Use a short, human-friendly chat name in sentence case (1-4 words). Pass an `agent-host-session://` session or chat link to target another chat, or omit `chat` to rename the chat in which this tool is running. Every invocation replaces the current title.", - "parameters": { - "type": "object", - "properties": { - "session": { - "type": "string", - "description": "Optional owning session: a session URI from `list_sessions` or an `agent-host-session://` link. When provided with `chat`, it must match that chat's session." - }, - "chat": { - "type": "string", - "description": "The chat to rename: pass an `agent-host-session://` session or chat link. Omit when renaming the chat in which this tool is running." - }, - "title": { - "type": "string", - "description": "Short, descriptive chat title, ideally 1-4 words.\n\n{maxLength: 200}" - } - }, - "required": [ - "title" - ] - }, - "strict": false, - "type": "function" - }, { "name": "send_message", "description": "Send, replace, or cancel your message in an existing session or chat. Use `delivery: \"steer\"` only to update an active turn; steering is handed to the provider immediately and cannot be replaced or cancelled. Use `delivery: \"queue\"` for a separate turn. Replace and cancel require the message ID and revision returned by an earlier call and succeed only while that queued message is pending. If it already started, the result reports `processing` or `completed`; decide whether to send a steering correction or a queued follow-up. Delivery is asynchronous — this tool does not wait for or return a reply.", @@ -965,6 +962,16 @@ }, "strict": false, "type": "function" + }, + { + "name": "isolate_session", + "description": "Move only the current chat to a new Git worktree when the user wants to continue this task without changing its current checkout. This preserves the chat and its history; it does not create a separate task or move other chats, even those sharing the same folder. The session workspace will include the new worktree. The original folder is unchanged, and uncommitted edits are not copied except configured worktree include-files. Requires a chat working in one local Git folder. Follow the tool confirmation flow. Make this the final tool call and end the turn after it succeeds; the host then moves this chat and continues its task automatically. Do not poll or repeat the request.", + "parameters": { + "type": "object", + "properties": {} + }, + "strict": false, + "type": "function" } ], "reasoning": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-luna.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-luna.prompt.md index 40d489e135fd57..d03c033b03b8f6 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-luna.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-luna.prompt.md @@ -780,6 +780,29 @@ "strict": false, "type": "function" }, + { + "name": "set_workspace", + "description": "Set the current chat's workspace while preserving its identity and history. If a workspace is already attached, use this tool only when the user explicitly asks to change this chat's workspace. Do not infer a workspace change from a file path, a request to inspect another repository, or ordinary work in the current workspace. If `isolation` is false and the requested folder is already this chat's effective working directory, skip this tool and the confirmation question; tell the user the chat already uses that workspace and continue normally. Compare against this chat's working directory, not its session's project root. Creating a new worktree from the same folder is a real change, not a no-op. For a workspace-less quick chat, attach a workspace only to modify its files or run commands requiring its project environment; not for self-contained scratch work on attachments, pasted/generated content, or throwaway/exportable artifacts. Other chats keep their workspaces. Set `isolation` to true for a new managed Git worktree or false to use the selected folder directly; files are not moved or copied to the selected folder. Immediately before every call, use the available user-input tool to ask one question confirming both workspace and isolation, even if already specified; tool approval is not confirmation. Make this the turn's final tool call and end the turn; the host changes the workspace and continues the original task automatically. Do not repeat the request.", + "parameters": { + "type": "object", + "properties": { + "workspaceFolder": { + "type": "string", + "description": "Absolute local folder path or file URI to set as the current chat's workspace. Use an exact path from the user or `list_sessions`; do not guess." + }, + "isolation": { + "type": "boolean", + "description": "Whether to create an isolated Git worktree and use it as the workspace. Include this choice in the required user confirmation immediately before calling this tool." + } + }, + "required": [ + "workspaceFolder", + "isolation" + ] + }, + "strict": false, + "type": "function" + }, { "name": "create_session", "description": "Create delegated work and start it with an initial prompt, either in a new chat sharing the current session's workspace, lifecycle, and aggregate diff, or in an independent session. Only supply `worktree` when the user explicitly requests working with or without a new worktree; never combine it with `currentSession`.", @@ -824,32 +847,6 @@ "strict": false, "type": "function" }, - { - "name": "rename_chat", - "description": "Rename one specific chat when the user explicitly asks to rename it. Automatic naming is handled by the host; do not call this tool to name a fresh chat. Renaming the default chat also names its owning session, while peer-chat titles remain independent. Use a short, human-friendly chat name in sentence case (1-4 words). Pass an `agent-host-session://` session or chat link to target another chat, or omit `chat` to rename the chat in which this tool is running. Every invocation replaces the current title.", - "parameters": { - "type": "object", - "properties": { - "session": { - "type": "string", - "description": "Optional owning session: a session URI from `list_sessions` or an `agent-host-session://` link. When provided with `chat`, it must match that chat's session." - }, - "chat": { - "type": "string", - "description": "The chat to rename: pass an `agent-host-session://` session or chat link. Omit when renaming the chat in which this tool is running." - }, - "title": { - "type": "string", - "description": "Short, descriptive chat title, ideally 1-4 words.\n\n{maxLength: 200}" - } - }, - "required": [ - "title" - ] - }, - "strict": false, - "type": "function" - }, { "name": "send_message", "description": "Send, replace, or cancel your message in an existing session or chat. Use `delivery: \"steer\"` only to update an active turn; steering is handed to the provider immediately and cannot be replaced or cancelled. Use `delivery: \"queue\"` for a separate turn. Replace and cancel require the message ID and revision returned by an earlier call and succeed only while that queued message is pending. If it already started, the result reports `processing` or `completed`; decide whether to send a steering correction or a queued follow-up. Delivery is asynchronous — this tool does not wait for or return a reply.", @@ -945,6 +942,16 @@ }, "strict": false, "type": "function" + }, + { + "name": "isolate_session", + "description": "Move only the current chat to a new Git worktree when the user wants to continue this task without changing its current checkout. This preserves the chat and its history; it does not create a separate task or move other chats, even those sharing the same folder. The session workspace will include the new worktree. The original folder is unchanged, and uncommitted edits are not copied except configured worktree include-files. Requires a chat working in one local Git folder. Follow the tool confirmation flow. Make this the final tool call and end the turn after it succeeds; the host then moves this chat and continues its task automatically. Do not poll or repeat the request.", + "parameters": { + "type": "object", + "properties": {} + }, + "strict": false, + "type": "function" } ], "reasoning": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-sol.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-sol.prompt.md index 59bde33a7472dc..917a177bf7c91f 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-sol.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-sol.prompt.md @@ -780,6 +780,29 @@ "strict": false, "type": "function" }, + { + "name": "set_workspace", + "description": "Set the current chat's workspace while preserving its identity and history. If a workspace is already attached, use this tool only when the user explicitly asks to change this chat's workspace. Do not infer a workspace change from a file path, a request to inspect another repository, or ordinary work in the current workspace. If `isolation` is false and the requested folder is already this chat's effective working directory, skip this tool and the confirmation question; tell the user the chat already uses that workspace and continue normally. Compare against this chat's working directory, not its session's project root. Creating a new worktree from the same folder is a real change, not a no-op. For a workspace-less quick chat, attach a workspace only to modify its files or run commands requiring its project environment; not for self-contained scratch work on attachments, pasted/generated content, or throwaway/exportable artifacts. Other chats keep their workspaces. Set `isolation` to true for a new managed Git worktree or false to use the selected folder directly; files are not moved or copied to the selected folder. Immediately before every call, use the available user-input tool to ask one question confirming both workspace and isolation, even if already specified; tool approval is not confirmation. Make this the turn's final tool call and end the turn; the host changes the workspace and continues the original task automatically. Do not repeat the request.", + "parameters": { + "type": "object", + "properties": { + "workspaceFolder": { + "type": "string", + "description": "Absolute local folder path or file URI to set as the current chat's workspace. Use an exact path from the user or `list_sessions`; do not guess." + }, + "isolation": { + "type": "boolean", + "description": "Whether to create an isolated Git worktree and use it as the workspace. Include this choice in the required user confirmation immediately before calling this tool." + } + }, + "required": [ + "workspaceFolder", + "isolation" + ] + }, + "strict": false, + "type": "function" + }, { "name": "create_session", "description": "Create delegated work and start it with an initial prompt, either in a new chat sharing the current session's workspace, lifecycle, and aggregate diff, or in an independent session. Only supply `worktree` when the user explicitly requests working with or without a new worktree; never combine it with `currentSession`.", @@ -824,32 +847,6 @@ "strict": false, "type": "function" }, - { - "name": "rename_chat", - "description": "Rename one specific chat when the user explicitly asks to rename it. Automatic naming is handled by the host; do not call this tool to name a fresh chat. Renaming the default chat also names its owning session, while peer-chat titles remain independent. Use a short, human-friendly chat name in sentence case (1-4 words). Pass an `agent-host-session://` session or chat link to target another chat, or omit `chat` to rename the chat in which this tool is running. Every invocation replaces the current title.", - "parameters": { - "type": "object", - "properties": { - "session": { - "type": "string", - "description": "Optional owning session: a session URI from `list_sessions` or an `agent-host-session://` link. When provided with `chat`, it must match that chat's session." - }, - "chat": { - "type": "string", - "description": "The chat to rename: pass an `agent-host-session://` session or chat link. Omit when renaming the chat in which this tool is running." - }, - "title": { - "type": "string", - "description": "Short, descriptive chat title, ideally 1-4 words.\n\n{maxLength: 200}" - } - }, - "required": [ - "title" - ] - }, - "strict": false, - "type": "function" - }, { "name": "send_message", "description": "Send, replace, or cancel your message in an existing session or chat. Use `delivery: \"steer\"` only to update an active turn; steering is handed to the provider immediately and cannot be replaced or cancelled. Use `delivery: \"queue\"` for a separate turn. Replace and cancel require the message ID and revision returned by an earlier call and succeed only while that queued message is pending. If it already started, the result reports `processing` or `completed`; decide whether to send a steering correction or a queued follow-up. Delivery is asynchronous — this tool does not wait for or return a reply.", @@ -945,6 +942,16 @@ }, "strict": false, "type": "function" + }, + { + "name": "isolate_session", + "description": "Move only the current chat to a new Git worktree when the user wants to continue this task without changing its current checkout. This preserves the chat and its history; it does not create a separate task or move other chats, even those sharing the same folder. The session workspace will include the new worktree. The original folder is unchanged, and uncommitted edits are not copied except configured worktree include-files. Requires a chat working in one local Git folder. Follow the tool confirmation flow. Make this the final tool call and end the turn after it succeeds; the host then moves this chat and continues its task automatically. Do not poll or repeat the request.", + "parameters": { + "type": "object", + "properties": {} + }, + "strict": false, + "type": "function" } ], "reasoning": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-terra.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-terra.prompt.md index d432d581ca2e98..a351b3348006f7 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-terra.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-terra.prompt.md @@ -780,6 +780,29 @@ "strict": false, "type": "function" }, + { + "name": "set_workspace", + "description": "Set the current chat's workspace while preserving its identity and history. If a workspace is already attached, use this tool only when the user explicitly asks to change this chat's workspace. Do not infer a workspace change from a file path, a request to inspect another repository, or ordinary work in the current workspace. If `isolation` is false and the requested folder is already this chat's effective working directory, skip this tool and the confirmation question; tell the user the chat already uses that workspace and continue normally. Compare against this chat's working directory, not its session's project root. Creating a new worktree from the same folder is a real change, not a no-op. For a workspace-less quick chat, attach a workspace only to modify its files or run commands requiring its project environment; not for self-contained scratch work on attachments, pasted/generated content, or throwaway/exportable artifacts. Other chats keep their workspaces. Set `isolation` to true for a new managed Git worktree or false to use the selected folder directly; files are not moved or copied to the selected folder. Immediately before every call, use the available user-input tool to ask one question confirming both workspace and isolation, even if already specified; tool approval is not confirmation. Make this the turn's final tool call and end the turn; the host changes the workspace and continues the original task automatically. Do not repeat the request.", + "parameters": { + "type": "object", + "properties": { + "workspaceFolder": { + "type": "string", + "description": "Absolute local folder path or file URI to set as the current chat's workspace. Use an exact path from the user or `list_sessions`; do not guess." + }, + "isolation": { + "type": "boolean", + "description": "Whether to create an isolated Git worktree and use it as the workspace. Include this choice in the required user confirmation immediately before calling this tool." + } + }, + "required": [ + "workspaceFolder", + "isolation" + ] + }, + "strict": false, + "type": "function" + }, { "name": "create_session", "description": "Create delegated work and start it with an initial prompt, either in a new chat sharing the current session's workspace, lifecycle, and aggregate diff, or in an independent session. Only supply `worktree` when the user explicitly requests working with or without a new worktree; never combine it with `currentSession`.", @@ -824,32 +847,6 @@ "strict": false, "type": "function" }, - { - "name": "rename_chat", - "description": "Rename one specific chat when the user explicitly asks to rename it. Automatic naming is handled by the host; do not call this tool to name a fresh chat. Renaming the default chat also names its owning session, while peer-chat titles remain independent. Use a short, human-friendly chat name in sentence case (1-4 words). Pass an `agent-host-session://` session or chat link to target another chat, or omit `chat` to rename the chat in which this tool is running. Every invocation replaces the current title.", - "parameters": { - "type": "object", - "properties": { - "session": { - "type": "string", - "description": "Optional owning session: a session URI from `list_sessions` or an `agent-host-session://` link. When provided with `chat`, it must match that chat's session." - }, - "chat": { - "type": "string", - "description": "The chat to rename: pass an `agent-host-session://` session or chat link. Omit when renaming the chat in which this tool is running." - }, - "title": { - "type": "string", - "description": "Short, descriptive chat title, ideally 1-4 words.\n\n{maxLength: 200}" - } - }, - "required": [ - "title" - ] - }, - "strict": false, - "type": "function" - }, { "name": "send_message", "description": "Send, replace, or cancel your message in an existing session or chat. Use `delivery: \"steer\"` only to update an active turn; steering is handed to the provider immediately and cannot be replaced or cancelled. Use `delivery: \"queue\"` for a separate turn. Replace and cancel require the message ID and revision returned by an earlier call and succeed only while that queued message is pending. If it already started, the result reports `processing` or `completed`; decide whether to send a steering correction or a queued follow-up. Delivery is asynchronous — this tool does not wait for or return a reply.", @@ -945,6 +942,16 @@ }, "strict": false, "type": "function" + }, + { + "name": "isolate_session", + "description": "Move only the current chat to a new Git worktree when the user wants to continue this task without changing its current checkout. This preserves the chat and its history; it does not create a separate task or move other chats, even those sharing the same folder. The session workspace will include the new worktree. The original folder is unchanged, and uncommitted edits are not copied except configured worktree include-files. Requires a chat working in one local Git folder. Follow the tool confirmation flow. Make this the final tool call and end the turn after it succeeds; the host then moves this chat and continues its task automatically. Do not poll or repeat the request.", + "parameters": { + "type": "object", + "properties": {} + }, + "strict": false, + "type": "function" } ], "reasoning": { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/copilotManagedSettingsAgentHostE2E.integrationTest.ts b/src/vs/platform/agentHost/test/node/e2e/providers/copilotManagedSettingsAgentHostE2E.integrationTest.ts index 2574146d2d25b4..a61392f5345055 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/copilotManagedSettingsAgentHostE2E.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/e2e/providers/copilotManagedSettingsAgentHostE2E.integrationTest.ts @@ -4,18 +4,22 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { mkdtemp } from 'fs/promises'; +import { existsSync } from 'fs'; +import { mkdtemp, readFile, writeFile } from 'fs/promises'; import { tmpdir } from 'os'; import { join } from '../../../../../../base/common/path.js'; +import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { GITHUB_COPILOT_PROTECTED_RESOURCE } from '../../../../common/agent.js'; import type { IAgentHostManagedSettingsDiagnostics } from '../../../../common/agentService.js'; -import type { ListSessionsResult } from '../../../../common/state/protocol/commands.js'; +import type { IAgentHostManagedSettingsPermissions } from '../../../../common/agentHostManagedSettings.js'; +import { SessionConfigKey } from '../../../../common/sessionConfigKeys.js'; +import type { ListSessionsResult, SubscribeResult } from '../../../../common/state/protocol/commands.js'; import { ActionType } from '../../../../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../../../../common/state/protocol/version/registry.js'; -import { ROOT_STATE_URI } from '../../../../common/state/sessionState.js'; -import { getActionEnvelope, isActionNotification } from '../../serverIntegrationTestHelpers.js'; -import { AgentHostE2EServerLease, removeTempDirs, resolveGitHubToken } from '../harness/agentHostE2ETestHarness.js'; +import { buildChatUri, buildDefaultChatUri, MessageKind, ROOT_STATE_URI, ToolCallCancellationReason, ToolCallConfirmationReason } from '../../../../common/state/sessionState.js'; +import { getActionEnvelope, isActionNotification, TestProtocolClient } from '../../serverIntegrationTestHelpers.js'; +import { AgentHostE2EServerLease, assertToolCallCompleteText, createRealSession, removeTempDirs, resolveGitHubToken } from '../harness/agentHostE2ETestHarness.js'; import { COPILOT_CONFIG } from './copilotTestConfiguration.js'; suite('Agent Host E2E — Copilot managed-settings diagnostics', function () { @@ -91,3 +95,206 @@ suite('Agent Host E2E — Copilot managed-settings diagnostics', function () { } }); }); + +suite('Agent Host E2E — Copilot managed permissions over AHP', function () { + ensureNoDisposablesAreLeakedInTestSuite(); + let client: TestProtocolClient; + let lease: AgentHostE2EServerLease; + let workspace: string; + let clientSeq: number; + const createdSessions: string[] = []; + const tempDirs: string[] = []; + + setup(async function () { + this.timeout(60_000); + workspace = await mkdtemp(join(tmpdir(), 'copilot-managed-ahp-')); + tempDirs.push(workspace); + await writeFile(join(workspace, 'input.txt'), 'MANAGED_READ_CONTENT'); + clientSeq = 1; + lease = new AgentHostE2EServerLease(COPILOT_CONFIG); + ({ client } = await lease.acquire(this.currentTest!.title)); + }); + + teardown(async function () { + this.timeout(120_000); + try { + if (this.currentTest?.state === 'failed') { + lease.dumpRuntimeLogsOnFailure(this.currentTest.title); + } + await lease.release(createdSessions, this.currentTest?.state === 'failed'); + } finally { + try { + await lease.dispose(); + } finally { + await removeTempDirs(tempDirs); + } + } + }); + + async function contribute(permissions: IAgentHostManagedSettingsPermissions): Promise { + client.notify('setClientManagedSettingsPermissions', { permissions }); + await client.call('listSessions', { channel: ROOT_STATE_URI }); + } + + async function createSession(permissions: IAgentHostManagedSettingsPermissions, mode = 'autoApprove'): Promise { + return createRealSession(client, { + ...COPILOT_CONFIG, + sessionConfig: { [SessionConfigKey.AutoApprove]: mode }, + }, 'managed-permissions-ahp', createdSessions, URI.file(workspace), () => contribute(permissions)); + } + + async function fileTurn(chat: string, turnId: string, operation: 'read' | 'write', approval: 'approve' | 'deny' | 'none' | 'blocked', filename = `${turnId}.txt`): Promise { + const prompt = operation === 'read' + ? 'Use view exactly once to read input.txt anew, even if you read it before. Do not use a shell. If denied, stop and reply denied.' + : `Use create exactly once to create ${filename} containing exactly MANAGED_WRITE_CONTENT. Do not use a shell. If denied, stop and reply denied without retrying or using another tool.`; + client.dispatch({ + channel: chat, + clientSeq: clientSeq++, + action: { type: ActionType.ChatTurnStarted, turnId, startedAt: new Date().toISOString(), message: { text: prompt, origin: { kind: MessageKind.User } } }, + }); + let afterServerSeq = 0; + let confirmations = 0; + while (true) { + const notification = await client.waitForNotification(notification => { + if (!isActionNotification(notification, ActionType.ChatToolCallReady) + && !isActionNotification(notification, ActionType.ChatTurnComplete) + && !isActionNotification(notification, ActionType.ChatError)) { + return false; + } + const envelope = getActionEnvelope(notification); + const action = envelope.action; + return envelope.channel === chat && envelope.serverSeq > afterServerSeq + && (action.type === ActionType.ChatToolCallReady || action.type === ActionType.ChatTurnComplete || action.type === ActionType.ChatError) + && action.turnId === turnId; + }, 90_000); + const envelope = getActionEnvelope(notification); + afterServerSeq = envelope.serverSeq; + const action = envelope.action; + if (action.type === ActionType.ChatError) { + throw new Error(`Managed permission turn failed: ${action.part.error.errorType}: ${action.part.error.message}`); + } + if (action.type === ActionType.ChatTurnComplete) { + break; + } + assert.ok(action.type === ActionType.ChatToolCallReady); + if (action.confirmed) { + continue; + } + confirmations++; + assert.ok(approval === 'approve' || approval === 'deny', 'Removed asks and managed denials must not require confirmation'); + assert.deepStrictEqual(action.options?.map(option => option.id), ['allow-once', 'skip']); + if (operation === 'write') { + assert.strictEqual(existsSync(join(workspace, filename)), false, 'The file must not be written before human approval'); + } + client.dispatch({ + channel: chat, + clientSeq: clientSeq++, + action: approval === 'approve' + ? { type: ActionType.ChatToolCallConfirmed, turnId, toolCallId: action.toolCallId, approved: true, confirmed: ToolCallConfirmationReason.UserAction, selectedOptionId: 'allow-once' } + : { type: ActionType.ChatToolCallConfirmed, turnId, toolCallId: action.toolCallId, approved: false, reason: ToolCallCancellationReason.Denied, selectedOptionId: 'skip' }, + }); + } + assert.strictEqual(confirmations, approval === 'approve' || approval === 'deny' ? 1 : 0); + if (approval === 'blocked') { + if (operation === 'write') { + assert.strictEqual(existsSync(join(workspace, filename)), false); + } + assertToolCallCompleteText(client, { channel: chat, turnId, toolNames: [operation === 'read' ? 'view' : 'create'], expected: [/denied|blocked|policy/i], success: false }); + } else if (approval === 'deny') { + assert.strictEqual(existsSync(join(workspace, filename)), false); + } else if (operation === 'write') { + assert.strictEqual(await readFile(join(workspace, filename), 'utf8'), 'MANAGED_WRITE_CONTENT'); + assertToolCallCompleteText(client, { channel: chat, turnId, toolNames: ['create'], expected: [], success: true }); + } else { + assertToolCallCompleteText(client, { channel: chat, turnId, toolNames: ['view'], expected: [/MANAGED_READ_CONTENT/], success: true }); + } + } + + for (const mode of ['autoApprove', 'assisted']) { + test(`managed read approval remains one-time under ${mode}`, async function () { + this.timeout(180_000); + const session = await createSession({ ask: ['Read'] }, mode); + const chat = buildDefaultChatUri(session); + await fileTurn(chat, 'managed-read-first', 'read', 'approve'); + await fileTurn(chat, 'managed-read-second', 'read', 'approve'); + }); + + test(`managed write approval remains one-time under ${mode}`, async function () { + this.timeout(180_000); + const session = await createSession({ ask: ['Write'] }, mode); + const chat = buildDefaultChatUri(session); + await fileTurn(chat, 'managed-write-first', 'write', 'approve'); + await fileTurn(chat, 'managed-write-second', 'write', 'approve'); + }); + + test(`managed write denial prevents mutation under ${mode}`, async function () { + this.timeout(180_000); + const session = await createSession({ ask: ['Write'] }, mode); + await fileTurn(buildDefaultChatUri(session), 'managed-write-denied', 'write', 'deny'); + }); + } + + for (const operation of ['read', 'write'] as const) { + test(`managed ${operation} denial blocks the native tool under Allow All`, async function () { + this.timeout(180_000); + const session = await createSession({ deny: [operation === 'read' ? 'Read' : 'Write'] }); + await fileTurn(buildDefaultChatUri(session), `managed-${operation}-blocked`, operation, 'blocked'); + }); + } + + test('removing managed asks refreshes both default and peer chats before their next turn', async function () { + this.timeout(180_000); + const session = await createSession({ ask: ['Write'] }); + const defaultChat = buildDefaultChatUri(session); + const peer = buildChatUri(session, 'managed-peer'); + await client.call('createChat', { channel: session, chat: peer }); + await client.call('subscribe', { channel: peer }); + await fileTurn(defaultChat, 'managed-default-before', 'write', 'approve'); + await fileTurn(peer, 'managed-peer-before', 'write', 'approve'); + await contribute({}); + await fileTurn(defaultChat, 'managed-default-after', 'write', 'none'); + await fileTurn(peer, 'managed-peer-after', 'write', 'none'); + }); + + test('adding managed asks refreshes both materialized default and peer chats', async function () { + this.timeout(180_000); + const session = await createSession({}); + const defaultChat = buildDefaultChatUri(session); + const peer = buildChatUri(session, 'managed-peer'); + await client.call('createChat', { channel: session, chat: peer }); + await client.call('subscribe', { channel: peer }); + await fileTurn(defaultChat, 'unmanaged-default-before', 'write', 'none'); + await fileTurn(peer, 'unmanaged-peer-before', 'write', 'none'); + await contribute({ ask: ['Write'] }); + await fileTurn(defaultChat, 'new-managed-default', 'write', 'approve'); + await fileTurn(peer, 'new-managed-peer', 'write', 'approve'); + }); + + test('cold host resume reapplies the current managed ask contribution', async function () { + this.timeout(180_000); + const session = await createSession({ ask: ['Write'] }); + await fileTurn(buildDefaultChatUri(session), 'managed-before-restart', 'write', 'approve'); + client = await lease.restart(); + await client.call('initialize', { channel: ROOT_STATE_URI, protocolVersions: [PROTOCOL_VERSION], clientId: 'managed-resumed-client' }); + await client.call('authenticate', { channel: ROOT_STATE_URI, resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, token: resolveGitHubToken() }); + await contribute({ ask: ['Write'] }); + await client.call('subscribe', { channel: session }); + await client.call('subscribe', { channel: buildDefaultChatUri(session) }); + clientSeq = 1; + await fileTurn(buildDefaultChatUri(session), 'managed-after-restart', 'write', 'approve'); + }); + + test('cold host resume does not persist a removed managed ask contribution', async function () { + this.timeout(180_000); + const session = await createSession({ ask: ['Write'] }); + await fileTurn(buildDefaultChatUri(session), 'managed-before-clear', 'write', 'approve'); + client = await lease.restart(); + await client.call('initialize', { channel: ROOT_STATE_URI, protocolVersions: [PROTOCOL_VERSION], clientId: 'managed-cleared-client' }); + await client.call('authenticate', { channel: ROOT_STATE_URI, resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, token: resolveGitHubToken() }); + await contribute({}); + await client.call('subscribe', { channel: session }); + await client.call('subscribe', { channel: buildDefaultChatUri(session) }); + clientSeq = 1; + await fileTurn(buildDefaultChatUri(session), 'managed-after-clear', 'write', 'none'); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/copilotOtelAgentHostE2E.integrationTest.ts b/src/vs/platform/agentHost/test/node/e2e/providers/copilotOtelAgentHostE2E.integrationTest.ts index 64704c9f3809c6..511c23626cabfe 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/copilotOtelAgentHostE2E.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/e2e/providers/copilotOtelAgentHostE2E.integrationTest.ts @@ -15,14 +15,19 @@ import { NullLogService } from '../../../../../log/common/log.js'; import { GenAiAttr } from '../../../../../otel/common/genAiAttributes.js'; import type { ICompletedSpanData } from '../../../../../otel/common/spanData.js'; import { type ILocalOtlpHttpReceiver, startLocalOtlpHttpReceiver } from '../../../../../otel/node/otlp/localOtlpReceiver.js'; +import { GITHUB_COPILOT_PROTECTED_RESOURCE } from '../../../../common/agent.js'; import { readAgentModelCallDiagnostics } from '../../../../common/meta/agentModelCallMeta.js'; -import { buildDefaultChatUri } from '../../../../common/state/sessionState.js'; -import { AgentHostE2EServerLease, createRealSession, driveTurnToCompletion, removeTempDirs } from '../harness/agentHostE2ETestHarness.js'; +import { ROOT_STATE_URI, buildDefaultChatUri } from '../../../../common/state/sessionState.js'; +import { PROTOCOL_VERSION } from '../../../../common/state/protocol/version/registry.js'; +import { AgentHostE2EServerLease, createRealSession, driveTurnToCompletion, removeTempDirs, resolveGitHubToken } from '../harness/agentHostE2ETestHarness.js'; import { fetchSessionWithChat, TestProtocolClient } from '../../serverIntegrationTestHelpers.js'; import type { CapiReplayProxy } from '../harness/capiReplayProxy.js'; import { normalizeVolatileText } from '../harness/capiWireCodec.js'; +import { assertExpectedFailure } from '../harness/expectedFailure.js'; import { COPILOT_CONFIG } from './copilotTestConfiguration.js'; +const RECORD = process.env['AGENT_HOST_REPLAY_RECORD'] === '1' || process.env['AGENT_HOST_UPDATE_SNAPSHOTS'] === '1'; + const otelTestEnv = { // Keep native batch export within the bounded wait, independent of inherited or default scheduling. OTEL_BSP_SCHEDULE_DELAY: '100', @@ -34,19 +39,31 @@ const otelTestEnv = { OTEL_EXPORTER_OTLP_TRACES_CLIENT_KEY: '', }; +interface ManagedTelemetryPolicy { + enabled?: boolean; + endpoint?: string; + protocol?: 'http/json'; + serviceName?: string; + captureContent?: boolean; + lockCaptureContent?: boolean; + capture?: { prompts?: boolean; responses?: boolean; identity?: boolean }; + resourceAttributes?: Record; +} + suite('Agent Host E2E — Copilot managed telemetry', function () { const createdSessions: string[] = []; const tempDirs: string[] = []; const spans: ICompletedSpanData[] = []; const decodeErrors: string[] = []; let client: TestProtocolClient; - let lease: AgentHostE2EServerLease; + let lease: AgentHostE2EServerLease | undefined; let proxy: CapiReplayProxy; let endpoint: string; let cacheHome: string; let workspace: string; let collector: ILocalOtlpHttpReceiver; let initialized: boolean; + let testTitle: string; teardown(async function () { this.timeout(120_000); @@ -69,6 +86,8 @@ suite('Agent Host E2E — Copilot managed telemetry', function () { spans.length = 0; decodeErrors.length = 0; initialized = false; + lease = undefined; + testTitle = this.currentTest!.title; const directory = await mkdtemp(join(tmpdir(), 'copilot-managed-otel-')); tempDirs.push(directory); workspace = join(directory, 'workspace'); @@ -83,6 +102,10 @@ suite('Agent Host E2E — Copilot managed telemetry', function () { }, }, new NullLogService())); endpoint = collector.baseUrl; + }); + + async function startHost(env: Record = {}): Promise { + assert.strictEqual(lease, undefined); lease = new AgentHostE2EServerLease(COPILOT_CONFIG, { env: { ...otelTestEnv, @@ -102,25 +125,33 @@ suite('Agent Host E2E — Copilot managed telemetry', function () { OTEL_RESOURCE_ATTRIBUTES: '', OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: 'false', OTEL_METRICS_EXPORTER: 'none', + ...env, }, }); - const acquired = await lease.acquire(this.currentTest!.title); + const acquired = await lease.acquire(testTitle); client = acquired.client; assert.ok(acquired.server.capiReplay); proxy = acquired.server.capiReplay; - }); + } - async function setPolicy(serviceName: string): Promise { + async function replacePolicy(telemetry: ManagedTelemetryPolicy | undefined): Promise { + if (!lease) { + await startHost(); + } proxy.setManagedSettings({ forceRemoteSettingsRefresh: false, - telemetry: { enabled: true, endpoint, protocol: 'http/json', serviceName, captureContent: true }, + ...(telemetry ? { telemetry } : {}), }); // Evict only this test's disposable policy cache, never the runtime or its sessions. await rm(join(cacheHome, 'managed-settings'), { recursive: true, force: true }); return proxy.managedSettingsRequestCount; } - async function completeCapturedTurn(serviceName: string, sentinel: string, previousRequests: number): Promise { + async function setPolicy(serviceName: string, overrides: ManagedTelemetryPolicy = {}): Promise { + return replacePolicy({ enabled: true, endpoint, protocol: 'http/json', serviceName, captureContent: true, ...overrides }); + } + + async function completePolicyTurn(sentinel: string, previousRequests: number): Promise<{ session: string; sdkSessionId: string; message: string }> { const message = `Reply exactly "${sentinel}".`; let session: string; if (!initialized) { @@ -145,12 +176,29 @@ suite('Agent Host E2E — Copilot managed telemetry', function () { assert.ok(turn?.usage, 'The completed turn must report model-call usage'); const diagnostics = readAgentModelCallDiagnostics(turn.usage); assert.ok(diagnostics, 'The completed turn must identify its provider session'); - const exported = await retry(async () => { - const span = spans.find(span => span.attributes[GenAiAttr.OPERATION_NAME] === 'chat' - && span.attributes[GenAiAttr.CONVERSATION_ID] === diagnostics.sdkSessionId); - assert.ok(span, `No inference span exported for ${sentinel}; received ${spans.map(span => span.name).join(', ')}`); + return { session, sdkSessionId: diagnostics.sdkSessionId, message }; + } + + async function inferenceSpan(sdkSessionId: string, received = spans, serviceName?: string): Promise { + return retry(async () => { + const span = received.find(span => span.attributes[GenAiAttr.OPERATION_NAME] === 'chat' + && span.attributes[GenAiAttr.CONVERSATION_ID] === sdkSessionId + && (serviceName === undefined || span.attributes['service.name'] === serviceName)); + assert.ok(span, `No inference span exported for ${sdkSessionId}; received ${received.map(span => span.name).join(', ')}`); return span; }, 100, 150); + } + + async function flushHost(): Promise { + assert.ok(lease); + await lease.release(createdSessions); + await lease.dispose(); + lease = undefined; + } + + async function completeCapturedTurn(serviceName: string, sentinel: string, previousRequests: number): Promise { + const { sdkSessionId, message } = await completePolicyTurn(sentinel, previousRequests); + const exported = await inferenceSpan(sdkSessionId); const input = exported.attributes['gen_ai.input.messages']; assert.strictEqual(typeof input, 'string', 'The inference span must capture structured input messages'); const messages: { role: string; parts: { type: string; content: string }[] }[] = JSON.parse(String(input)); @@ -184,10 +232,204 @@ suite('Agent Host E2E — Copilot managed telemetry', function () { this.timeout(180_000); await completeCapturedTurn('otel-policy-a', 'otel-restart-first', await setPolicy('otel-policy-a')); const previousRequests = await setPolicy('otel-policy-b'); + assert.ok(lease); client = await lease.restart(); initialized = false; await completeCapturedTurn('otel-policy-b', 'otel-restart-second', previousRequests); }); + + for (const restriction of [ + { name: 'captureContent denial', policy: { captureContent: false }, prompts: false, responses: false }, + { name: 'prompt capture denial', policy: { capture: { prompts: false } }, prompts: false, responses: true }, + { name: 'response capture denial', policy: { capture: { responses: false } }, prompts: true, responses: false }, + { name: 'locked content capture', policy: { captureContent: undefined, lockCaptureContent: true }, prompts: false, responses: false }, + { name: 'prompt-only capture', policy: { captureContent: false, capture: { prompts: true } }, prompts: true, responses: false }, + { name: 'response-only capture', policy: { captureContent: false, capture: { responses: true } }, prompts: false, responses: true }, + ] satisfies { name: string; policy: ManagedTelemetryPolicy; prompts: boolean; responses: boolean }[]) { + test(`managed ${restriction.name} overrides local content opt-in`, async function () { + this.timeout(180_000); + await startHost({ OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: 'true' }); + const turn = await completePolicyTurn('otel-private-message', await setPolicy('otel-private', restriction.policy)); + const span = await inferenceSpan(turn.sdkSessionId); + assert.deepStrictEqual({ + prompts: typeof span.attributes['gen_ai.input.messages'] === 'string', + responses: typeof span.attributes['gen_ai.output.messages'] === 'string', + serviceName: span.attributes['service.name'], + decodeErrors, + }, { prompts: restriction.prompts, responses: restriction.responses, serviceName: 'otel-private', decodeErrors: [] }); + await flushHost(); + const exported = JSON.stringify(spans.filter(span => span.attributes[GenAiAttr.CONVERSATION_ID] === turn.sdkSessionId)); + if (!restriction.prompts && !restriction.responses) { + assert.ok(!exported.includes('otel-private-message'), 'Denied content must not leak through another span or event'); + } + }); + } + + (RECORD ? test.skip : test)('managed identity denial removes inherited identity from native spans', async function () { + this.timeout(180_000); + await startHost({ + COPILOT_OTEL_CAPTURE_IDENTITY: 'true', + OTEL_RESOURCE_ATTRIBUTES: 'user.name=synthetic-account,process.user.name=synthetic-user,host.name=synthetic-host', + }); + const turn = await completePolicyTurn('otel-identity', await setPolicy('otel-identity', { capture: { identity: false } })); + await inferenceSpan(turn.sdkSessionId); + await flushHost(); + const native = spans.filter(span => span.attributes[GenAiAttr.CONVERSATION_ID] === turn.sdkSessionId + && span.attributes['service.name'] === 'otel-identity'); + assert.ok(native.length > 0); + const identityKeys = native.flatMap(span => [span.attributes, ...span.events.map(event => event.attributes ?? {})]) + .flatMap(attributes => Object.keys(attributes).filter(key => ['user.name', 'process.user.name', 'host.name'].includes(key))); + assert.deepStrictEqual(identityKeys.filter(key => key !== 'user.name'), []); + await assertExpectedFailure('Copilot native inherited identity redaction (runtime 1.0.92-4)', + /^Managed identity denial retained inherited identity resource attributes/, () => { + assert.ok(identityKeys.length === 0, 'Managed identity denial retained inherited identity resource attributes'); + assert.ok(!JSON.stringify(native).includes('synthetic-account')); + }); + }); + + test('managed telemetry disablement overrides an environment-selected collector', async function () { + this.timeout(180_000); + await startHost({ + COPILOT_OTEL_ENABLED: 'true', + OTEL_EXPORTER_OTLP_ENDPOINT: endpoint, + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: `${endpoint}/v1/traces`, + OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: 'true', + }); + const turn = await completePolicyTurn('otel-disabled-message', await setPolicy('otel-disabled', { enabled: false })); + await flushHost(); + assert.deepStrictEqual(spans.filter(span => span.attributes['service.name'] === 'otel-disabled' + || (span.attributes[GenAiAttr.CONVERSATION_ID] === turn.sdkSessionId + && ['chat', 'invoke_agent'].includes(String(span.attributes[GenAiAttr.OPERATION_NAME])))), []); + }); + + test('new sessions route managed telemetry to different collectors without restarting', async function () { + this.timeout(180_000); + const secondSpans: ICompletedSpanData[] = []; + const second = store.add(await startLocalOtlpHttpReceiver({ + onSpans: result => { + secondSpans.push(...result.spans); + if (result.rejected) { + decodeErrors.push(`${result.rejected} rejected spans: ${result.errors.join(', ')}`); + } + } + }, new NullLogService())); + const first = await completePolicyTurn('otel-collector-first', await setPolicy('otel-collector-a')); + await inferenceSpan(first.sdkSessionId); + const next = await completePolicyTurn('otel-collector-second', await setPolicy('otel-collector-b', { endpoint: second.baseUrl })); + const exported = await inferenceSpan(next.sdkSessionId, secondSpans); + await flushHost(); + assert.deepStrictEqual({ + serviceName: exported.attributes['service.name'], + firstAtSecond: secondSpans.some(span => span.attributes[GenAiAttr.CONVERSATION_ID] === first.sdkSessionId), + secondAtFirst: spans.some(span => span.attributes[GenAiAttr.CONVERSATION_ID] === next.sdkSessionId), + decodeErrors, + }, { serviceName: 'otel-collector-b', firstAtSecond: false, secondAtFirst: false, decodeErrors: [] }); + }); + + test('new sessions revoke managed content capture without restarting', async function () { + this.timeout(180_000); + await completeCapturedTurn('otel-revoke', 'otel-before-revocation', await setPolicy('otel-revoke')); + const turn = await completePolicyTurn('otel-after-revocation', await setPolicy('otel-revoke', { captureContent: false })); + await inferenceSpan(turn.sdkSessionId); + await flushHost(); + const native = spans.filter(span => span.attributes[GenAiAttr.CONVERSATION_ID] === turn.sdkSessionId); + assert.ok(!JSON.stringify(native).includes('otel-after-revocation')); + assert.deepStrictEqual(native.filter(span => span.attributes['gen_ai.input.messages'] !== undefined + || span.attributes['gen_ai.output.messages'] !== undefined), []); + }); + + test('removing managed telemetry does not retain the previous exporter in a new session', async function () { + this.timeout(180_000); + await completeCapturedTurn('otel-remove', 'otel-before-removal', await setPolicy('otel-remove')); + const turn = await completePolicyTurn('otel-after-removal', await replacePolicy(undefined)); + await flushHost(); + assert.deepStrictEqual(spans.filter(span => span.attributes[GenAiAttr.CONVERSATION_ID] === turn.sdkSessionId), []); + assert.ok(!JSON.stringify(spans).includes('otel-after-removal')); + }); + + test('managed prompt capture can be reenabled after a denied session', async function () { + this.timeout(180_000); + const denied = await completePolicyTurn('otel-capture-denied', await setPolicy('otel-enable', { captureContent: false })); + await inferenceSpan(denied.sdkSessionId); + await completeCapturedTurn('otel-enable', 'otel-capture-enabled', await setPolicy('otel-enable')); + await flushHost(); + assert.ok(!JSON.stringify(spans.filter(span => span.attributes[GenAiAttr.CONVERSATION_ID] === denied.sdkSessionId)).includes('otel-capture-denied')); + }); + + test('managed service identity overrides inherited service resource attributes', async function () { + this.timeout(180_000); + await startHost({ OTEL_SERVICE_NAME: 'local-service', OTEL_RESOURCE_ATTRIBUTES: 'service.name=local-resource-service' }); + const turn = await completePolicyTurn('otel-service-precedence', await setPolicy('managed-service')); + const exported = await inferenceSpan(turn.sdkSessionId); + assert.strictEqual(exported.attributes['service.name'], 'managed-service'); + }); + + test('managed resource attributes override conflicts and retain unrelated environment attributes', async function () { + this.timeout(180_000); + await startHost({ OTEL_RESOURCE_ATTRIBUTES: 'deployment.environment.name=local,synthetic.environment=retained' }); + const turn = await completePolicyTurn('otel-resource-precedence', await setPolicy('otel-resources', { + resourceAttributes: { 'deployment.environment.name': 'managed', 'synthetic.policy': 'managed-value' }, + })); + const exported = await inferenceSpan(turn.sdkSessionId); + assert.deepStrictEqual({ + environment: exported.attributes['deployment.environment.name'], + retained: exported.attributes['synthetic.environment'], + policy: exported.attributes['synthetic.policy'], + }, { environment: 'managed', retained: 'retained', policy: 'managed-value' }); + }); + + test('managed JSON protocol overrides inherited generic and trace-specific protobuf protocols', async function () { + this.timeout(180_000); + await startHost({ OTEL_EXPORTER_OTLP_PROTOCOL: 'http/protobuf', OTEL_EXPORTER_OTLP_TRACES_PROTOCOL: 'http/protobuf' }); + const turn = await completePolicyTurn('otel-protocol-precedence', await setPolicy('otel-json')); + const exported = await inferenceSpan(turn.sdkSessionId); + assert.deepStrictEqual({ serviceName: exported.attributes['service.name'], decodeErrors }, { serviceName: 'otel-json', decodeErrors: [] }); + }); + + test('managed collector overrides an inherited trace-specific endpoint', async function () { + this.timeout(180_000); + const localSpans: ICompletedSpanData[] = []; + const local = store.add(await startLocalOtlpHttpReceiver({ + onSpans: result => { + localSpans.push(...result.spans); + if (result.rejected) { + decodeErrors.push(`${result.rejected} rejected spans: ${result.errors.join(', ')}`); + } + } + }, new NullLogService())); + await startHost({ + COPILOT_OTEL_ENABLED: 'true', + OTEL_EXPORTER_OTLP_ENDPOINT: local.baseUrl, + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: `${local.baseUrl}/v1/traces`, + }); + const turn = await completePolicyTurn('otel-endpoint-precedence', await setPolicy('otel-managed-endpoint')); + await inferenceSpan(turn.sdkSessionId); + await flushHost(); + assert.deepStrictEqual(localSpans.filter(span => span.attributes[GenAiAttr.CONVERSATION_ID] === turn.sdkSessionId + && span.attributes['service.name'] === 'otel-managed-endpoint'), []); + assert.deepStrictEqual(decodeErrors, []); + }); + + test('cold session resume applies revoked managed capture instead of the persisted policy', async function () { + this.timeout(180_000); + const first = await completePolicyTurn('otel-resume-before', await setPolicy('otel-resume-captured')); + await inferenceSpan(first.sdkSessionId); + const previousRequests = await setPolicy('otel-resume-denied', { captureContent: false }); + assert.ok(lease); + client = await lease.restart(); + await client.call('initialize', { channel: ROOT_STATE_URI, protocolVersions: [PROTOCOL_VERSION], clientId: 'otel-resume-client' }); + await client.call('authenticate', { channel: ROOT_STATE_URI, resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, token: resolveGitHubToken() }); + await client.call('subscribe', { channel: first.session }); + await client.call('subscribe', { channel: buildDefaultChatUri(first.session) }); + await driveTurnToCompletion(client, first.session, 'otel-resume-after', 'Reply exactly "otel-resume-after".', 1); + assert.ok(proxy.managedSettingsRequestCount > previousRequests); + const exported = await inferenceSpan(first.sdkSessionId, spans, 'otel-resume-denied'); + assert.deepStrictEqual({ + input: exported.attributes['gen_ai.input.messages'], + output: exported.attributes['gen_ai.output.messages'], + decodeErrors, + }, { input: undefined, output: undefined, decodeErrors: [] }); + }); }); suite('Agent Host E2E — Copilot OTel file exporter', function () { 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 b5d4ff3733881f..3f26f26529b581 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/agentHostE2ESuites.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/agentHostE2ESuites.ts @@ -34,6 +34,7 @@ import { defineCopilotRuntimeToolsTests } from './copilotRuntimeToolsSuite.js'; import { defineManagementExtensionTests } from './managementExtensionsSuite.js'; import { defineAutomationsTests } from './automationsSuite.js'; import { defineDetachedWorktreeTests } from './detachedWorktreeSuite.js'; +import { defineTerminalResilienceTests } from './terminalResilienceSuite.js'; import type { AgentHostE2ETier, IAgentHostE2ETestContext } from './e2eTestContext.js'; const isLinux = process.platform === 'linux'; @@ -168,6 +169,7 @@ function defineSuite(config: IAgentHostE2EProviderConfig, options: IDefineOption defineAnnotationsTests(context); defineProtocolContractTests(context); defineDetachedWorktreeTests(context); + defineTerminalResilienceTests(context); } // Suites that contain only parity-tier scenarios. diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts index 7250956fd902d5..5a79bee8c0bbf2 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts @@ -25,7 +25,7 @@ import assert from 'assert'; import { execFileSync, execSync } from 'child_process'; -import { existsSync, mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'fs'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { retry } from '../../../../../../base/common/async.js'; import { join } from '../../../../../../base/common/path.js'; @@ -52,6 +52,8 @@ import { } from '../../../../common/changesetUri.js'; import { createRealSession, dispatchTurn, driveChatTurnToCompletion, driveTurnToCompletion, initTestGitRepo, resolveGitHubToken } from '../harness/agentHostE2ETestHarness.js'; import { getActionEnvelope, getAgentHostE2ETestTimeout, isActionNotification } from '../../serverIntegrationTestHelpers.js'; +import { assertExpectedFailure } from '../harness/expectedFailure.js'; +import { vscodeAgentHostTarget } from '../harness/agentHostTarget.js'; import { conformanceTest, type IAgentHostE2ETestContext } from './e2eTestContext.js'; /** The subset of `ChangesetFile` these tests assert on. */ @@ -364,6 +366,188 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void { return execFileSync('git', ['branch', '--show-current'], { cwd: workspace, encoding: 'utf8' }).trim(); } + async function readChangesetBytes(uri: string | undefined): Promise { + assert.ok(uri); + const result = await context.client.call('resourceRead', { uri, encoding: ContentEncoding.Base64 }); + return Buffer.from(result.data, result.encoding === ContentEncoding.Base64 ? 'base64' : 'utf8'); + } + + conformanceTest(context, 'regression coverage: staged changes appear in the branch changeset', async function () { + const workspace = createGitWorkspace('ahp-staged-branch-'); + writeFileSync(join(workspace, 'seed.txt'), 'staged\n'); + execFileSync('git', ['add', 'seed.txt'], { cwd: workspace }); + const session = await createSessionIn(workspace, 'staged-branch'); + const channel = await getBranchChangesetUri(context, session); + const [file] = await waitForChangesetFiles(channel, ['seed.txt']); + assert.deepStrictEqual({ + before: (await readChangesetBytes(file.edit.before?.content?.uri)).toString(), + after: (await readChangesetBytes(file.edit.after?.content?.uri)).toString(), + staged: execFileSync('git', ['diff', '--cached', '--name-only'], { cwd: workspace, encoding: 'utf8' }).trim(), + }, { before: 'seed\n', after: 'staged\n', staged: 'seed.txt' }); + }); + + conformanceTest(context, 'regression coverage: branch content uses working bytes rather than an older staged version', async function () { + const workspace = createGitWorkspace('ahp-index-worktree-'); + writeFileSync(join(workspace, 'seed.txt'), 'staged\n'); + execFileSync('git', ['add', 'seed.txt'], { cwd: workspace }); + writeFileSync(join(workspace, 'seed.txt'), 'working\n'); + const session = await createSessionIn(workspace, 'index-worktree'); + const [file] = await waitForChangesetFiles(await getBranchChangesetUri(context, session), ['seed.txt']); + assert.deepStrictEqual({ + before: (await readChangesetBytes(file.edit.before?.content?.uri)).toString(), + after: (await readChangesetBytes(file.edit.after?.content?.uri)).toString(), + index: execFileSync('git', ['show', ':seed.txt'], { cwd: workspace, encoding: 'utf8' }), + }, { before: 'seed\n', after: 'working\n', index: 'staged\n' }); + }); + + conformanceTest(context, 'regression coverage: binary changeset references preserve both byte sequences', async function () { + const workspace = createGitWorkspace('ahp-binary-changeset-'); + const before = Buffer.from([0, 255, 254, 1]); + const after = Buffer.from([0, 128, 129, 2, 3]); + writeFileSync(join(workspace, 'binary.dat'), before); + execFileSync('git', ['add', 'binary.dat'], { cwd: workspace }); + execFileSync('git', ['commit', '-q', '-m', 'binary seed'], { cwd: workspace }); + const session = await createSessionIn(workspace, 'binary-changeset'); + const channel = await getBranchChangesetUri(context, session); + await runBangTurn(session, 'binary-update', '!node -e "require(\'fs\').writeFileSync(\'binary.dat\',Buffer.from([0,128,129,2,3]))"', 1); + const [file] = await waitForChangesetFiles(channel, ['binary.dat']); + assert.deepStrictEqual(await readChangesetBytes(file.edit.after?.content?.uri), after); + const historical = await readChangesetBytes(file.edit.before?.content?.uri); + const assertHistoricalBytes = () => assert.ok(historical.equals(before), 'Historical Git blob did not preserve binary bytes'); + if (context.targetId === vscodeAgentHostTarget.id) { + await assertExpectedFailure('Agent Host historical binary Git blob transport', + /^Historical Git blob did not preserve binary bytes/, assertHistoricalBytes); + } else { + assertHistoricalBytes(); + } + }); + + for (const { title, filename } of [ + { title: 'URI-reserved filenames remain readable through changeset references', filename: 'percent%hash#.txt' }, + { title: 'UTF-8 filenames survive Git changeset decoding', filename: 'caf\u00e9.txt' }, + ]) { + conformanceTest(context, `regression coverage: ${title}`, async function () { + const workspace = createGitWorkspace('ahp-encoded-changeset-'); + const session = await createSessionIn(workspace, 'encoded-changeset'); + const channel = await getBranchChangesetUri(context, session); + await runBangTurn(session, 'encoded-add', writeFileBase64Command(filename, 'encoded content\n'), 1); + const [file] = await waitForChangesetFiles(channel, [filename]); + assert.deepStrictEqual({ + name: URI.parse(fileUri(file)).path.split('/').at(-1), + bytes: (await readChangesetBytes(file.edit.after?.content?.uri)).toString(), + }, { name: filename, bytes: 'encoded content\n' }); + }); + } + + conformanceTest(context, 'regression coverage: an added empty file remains present in the changeset', async function () { + const workspace = createGitWorkspace('ahp-empty-changeset-'); + const session = await createSessionIn(workspace, 'empty-changeset'); + const channel = await getBranchChangesetUri(context, session); + await runBangTurn(session, 'empty-add', '!node -e "require(\'fs\').writeFileSync(\'empty.txt\',\'\')"', 1); + assert.strictEqual(readFileSync(join(workspace, 'empty.txt')).length, 0); + const [file] = await waitForChangesetFiles(channel, ['empty.txt']); + assert.deepStrictEqual({ before: file.edit.before, after: !!file.edit.after, bytes: await readChangesetBytes(file.edit.after?.content?.uri) }, + { before: undefined, after: true, bytes: Buffer.alloc(0) }); + }); + + conformanceTest(context, 'regression coverage: a staged mode-only change is not discarded as an empty diff', async function () { + const workspace = createGitWorkspace('ahp-mode-changeset-'); + if (!context.isWindows) { + chmodSync(join(workspace, 'seed.txt'), 0o755); + } + execFileSync('git', ['update-index', '--chmod=+x', 'seed.txt'], { cwd: workspace }); + const session = await createSessionIn(workspace, 'mode-changeset'); + const [file] = await waitForChangesetFiles(await getBranchChangesetUri(context, session), ['seed.txt']); + assert.deepStrictEqual({ + before: !!file.edit.before, after: !!file.edit.after, + indexMode: execFileSync('git', ['ls-files', '--stage', 'seed.txt'], { cwd: workspace, encoding: 'utf8' }).split(' ')[0], + }, { before: true, after: true, indexMode: '100755' }); + }); + + conformanceTest(context, 'regression coverage: a nested folder session preserves repository-wide branch changes', async function () { + const workspace = createGitWorkspace('ahp-scoped-changeset-'); + mkdirSync(join(workspace, 'nested')); + writeFileSync(join(workspace, 'nested', 'inside.txt'), 'before\n'); + execFileSync('git', ['add', '.'], { cwd: workspace }); + execFileSync('git', ['commit', '-q', '-m', 'nested seed'], { cwd: workspace }); + writeFileSync(join(workspace, 'seed.txt'), 'outside\n'); + const session = await createSessionIn(join(workspace, 'nested'), 'scoped-changeset'); + const channel = await getBranchChangesetUri(context, session); + await runBangTurn(session, 'inside-update', writeFileCommand('inside.txt', 'inside'), 1); + await waitForChangesetFiles(channel, ['inside.txt']); + const files = (await changesetState(channel)).files; + assert.deepStrictEqual(files.map(file => URI.parse(fileUri(file)).path.split('/').at(-1)).sort(), ['inside.txt', 'seed.txt']); + assert.strictEqual(readFileSync(join(workspace, 'seed.txt'), 'utf8'), 'outside\n'); + assert.strictEqual(readFileSync(join(workspace, 'nested', 'inside.txt'), 'utf8'), 'inside'); + }); + + conformanceTest(context, 'regression coverage: discarding working edits preserves a staged sibling', async function () { + const workspace = createGitWorkspace('ahp-discard-index-sibling-'); + writeFileSync(join(workspace, 'staged.txt'), 'staged sibling\n'); + execFileSync('git', ['add', 'staged.txt'], { cwd: workspace }); + const session = await createSessionIn(workspace, 'discard-index-sibling'); + const channel = buildUncommittedChangesetUri(session); + await runBangTurn(session, 'dirty-seed', writeFileCommand('seed.txt', 'dirty'), 1); + const files = await waitForChangesetFiles(channel, ['seed.txt', 'staged.txt']); + const target = files.find(file => fileHasBasename(file, 'seed.txt')); + assert.ok(target); + await waitForOperation(channel, 'discard-changes'); + await invokeDiscard(channel, fileUri(target)); + assert.deepStrictEqual({ + seed: readFileSync(join(workspace, 'seed.txt'), 'utf8').replaceAll('\r\n', '\n'), + staged: execFileSync('git', ['diff', '--cached', '--name-only'], { cwd: workspace, encoding: 'utf8' }).trim(), + sibling: readFileSync(join(workspace, 'staged.txt'), 'utf8'), + }, { seed: 'seed\n', staged: 'staged.txt', sibling: 'staged sibling\n' }); + }); + + conformanceTest(context, 'regression coverage: discarding an unstaged layer restores the staged bytes', async function () { + const workspace = createGitWorkspace('ahp-discard-working-layer-'); + writeFileSync(join(workspace, 'seed.txt'), 'staged\n'); + execFileSync('git', ['add', 'seed.txt'], { cwd: workspace }); + const session = await createSessionIn(workspace, 'discard-working-layer'); + const channel = buildUncommittedChangesetUri(session); + await runBangTurn(session, 'working-layer', writeFileCommand('seed.txt', 'working'), 1); + const [target] = await waitForChangesetFiles(channel, ['seed.txt']); + await waitForOperation(channel, 'discard-changes'); + await invokeDiscard(channel, fileUri(target)); + assert.deepStrictEqual({ + working: readFileSync(join(workspace, 'seed.txt'), 'utf8').replaceAll('\r\n', '\n'), + index: execFileSync('git', ['show', ':seed.txt'], { cwd: workspace, encoding: 'utf8' }), + }, { working: 'staged\n', index: 'staged\n' }); + }); + + conformanceTest(context, 'regression coverage: discarding a binary edit restores committed bytes', async function () { + const workspace = createGitWorkspace('ahp-discard-binary-'); + const original = Buffer.from([0, 255, 254, 42]); + writeFileSync(join(workspace, 'binary.dat'), original); + execFileSync('git', ['add', 'binary.dat'], { cwd: workspace }); + execFileSync('git', ['commit', '-q', '-m', 'binary seed'], { cwd: workspace }); + const session = await createSessionIn(workspace, 'discard-binary'); + const channel = buildUncommittedChangesetUri(session); + await runBangTurn(session, 'binary-dirty', '!node -e "require(\'fs\').writeFileSync(\'binary.dat\',Buffer.from([0,128]))"', 1); + const [target] = await waitForChangesetFiles(channel, ['binary.dat']); + await waitForOperation(channel, 'discard-changes'); + await invokeDiscard(channel, fileUri(target)); + assert.deepStrictEqual(readFileSync(join(workspace, 'binary.dat')), original); + }); + + conformanceTest(context, 'regression coverage: discarding a nested untracked file preserves ignored sibling data', async function () { + const workspace = createGitWorkspace('ahp-discard-ignored-sibling-'); + writeFileSync(join(workspace, '.gitignore'), 'nested/keep.log\n'); + execFileSync('git', ['add', '.gitignore'], { cwd: workspace }); + execFileSync('git', ['commit', '-q', '-m', 'ignored sibling'], { cwd: workspace }); + mkdirSync(join(workspace, 'nested')); + writeFileSync(join(workspace, 'nested', 'keep.log'), 'ignored witness'); + const session = await createSessionIn(workspace, 'discard-ignored-sibling'); + const channel = buildUncommittedChangesetUri(session); + await runBangTurn(session, 'untracked-nested', writeFileBase64Command('nested/remove.txt', 'remove'), 1); + const [target] = await waitForChangesetFiles(channel, ['remove.txt']); + await waitForOperation(channel, 'discard-changes'); + await invokeDiscard(channel, fileUri(target)); + assert.deepStrictEqual({ removed: existsSync(join(workspace, 'nested', 'remove.txt')), kept: readFileSync(join(workspace, 'nested', 'keep.log'), 'utf8') }, + { removed: false, kept: 'ignored witness' }); + }); + conformanceTest(context, 'checkout lifecycle: an unused session checks out an existing branch', async function () { const { workspace } = createCheckoutWorkspace('ahp-checkout-clean-'); const channel = await checkoutChannel(workspace, 'checkout-clean'); diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/copilotRuntimeToolsSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/copilotRuntimeToolsSuite.ts index 0a211d533b1b48..c925d3dd2a5fc4 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/copilotRuntimeToolsSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/copilotRuntimeToolsSuite.ts @@ -13,9 +13,9 @@ import type { ResourceReadResult, SubscribeResult } from '../../../../common/sta import { ContentEncoding } from '../../../../common/state/protocol/common/commands.js'; import { PROTOCOL_VERSION } from '../../../../common/state/protocol/version/registry.js'; import { ActionType, type ChatErrorAction, type ChatToolCallCompleteAction, type ChatToolCallReadyAction, type ChatToolCallStartAction } from '../../../../common/state/sessionActions.js'; -import { buildDefaultChatUri, getErrorResponsePart, getInlineToolInput, ROOT_STATE_URI, ToolCallConfirmationReason, ToolCallContributorKind, ToolResultContentType, TurnState, type ToolDefinition } from '../../../../common/state/sessionState.js'; +import { buildChatUri, buildDefaultChatUri, getErrorResponsePart, getInlineToolInput, ROOT_STATE_URI, ToolCallConfirmationReason, ToolCallContributorKind, ToolResultContentType, TurnState, type ToolCallResult, type ToolDefinition } from '../../../../common/state/sessionState.js'; import { fetchSessionWithChat, getActionEnvelope, isActionNotification, type TestProtocolClient } from '../../serverIntegrationTestHelpers.js'; -import { createRealSession, dispatchTurn, driveTurnToCompletion, driveTurnWithModelToCompletion, textFromContent } from '../harness/agentHostE2ETestHarness.js'; +import { createRealSession, dispatchTurn, driveChatTurnToCompletion, driveTurnToCompletion, driveTurnWithModelToCompletion, resolveGitHubToken, textFromContent } from '../harness/agentHostE2ETestHarness.js'; import { anthropicMessageToSse } from '../harness/capiWireCodec.js'; import type { IAgentHostE2ETestContext } from './e2eTestContext.js'; @@ -44,6 +44,235 @@ export function defineCopilotRuntimeToolsTests(context: IAgentHostE2ETestContext return client; } + let liveToolSequence = 300_000; + + function nextLiveToolSequence(): number { + const value = liveToolSequence; + liveToolSequence += 100; + return value; + } + + function probeDefinition(name: string, field?: string, description = 'Returns the current probe value'): ToolDefinition { + return { + name, + description, + inputSchema: { type: 'object', properties: field ? { [field]: { type: 'string' } } : {}, required: field ? [field] : [] }, + }; + } + + async function publishTools(session: string, clientId: string, tools: readonly ToolDefinition[]): Promise { + const clientSeq = nextLiveToolSequence(); + context.client.dispatch({ + channel: session, clientSeq, + action: { type: ActionType.SessionActiveClientSet, activeClient: { clientId, tools: [...tools] } }, + }); + const changed = await context.client.waitForNotification(notification => + isActionNotification(notification, ActionType.SessionActiveClientSet) + && getActionEnvelope(notification).channel === session + && getActionEnvelope(notification).origin?.clientSeq === clientSeq, + ); + assert.strictEqual(getActionEnvelope(changed).rejectionReason, undefined); + } + + async function withLiveTools(prefix: string, run: (session: { sessionUri: string; workspace: string; clientId: string }) => Promise): Promise { + const session = { ...await createSession(prefix), clientId: prefix }; + await driveTurnToCompletion(context.client, session.sessionUri, 'tools-warmup', 'Reply exactly "READY". Do not call tools.', nextLiveToolSequence()); + try { + await run(session); + } finally { + await publishTools(session.sessionUri, session.clientId, []); + } + } + + async function executeClientProbe(chat: string, clientId: string, toolName: string, turnId: string, prompt: string, result: ToolCallResult = { + success: true, + pastTenseMessage: 'Completed probe', + content: [{ type: ToolResultContentType.Text, text: 'LIVE_PROBE_RESULT' }], + }): Promise> { + const [, input] = await Promise.all([ + driveChatTurnToCompletion(context.client, chat, turnId, prompt, nextLiveToolSequence()), + (async () => { + const start = await context.client.waitForNotification(notification => + isActionNotification(notification, ActionType.ChatToolCallStart) + && getActionEnvelope(notification).channel === chat + && (getActionEnvelope(notification).action as ChatToolCallStartAction).turnId === turnId + && (getActionEnvelope(notification).action as ChatToolCallStartAction).toolName === toolName, + 90_000, + ); + const started = getActionEnvelope(start).action; + assert.ok(started.type === ActionType.ChatToolCallStart); + const notification = await context.client.waitForNotification(notification => + isActionNotification(notification, ActionType.ChatToolCallReady) + && getActionEnvelope(notification).channel === chat + && (getActionEnvelope(notification).action as ChatToolCallReadyAction).toolCallId === started.toolCallId, + 90_000, + ); + const ready = getActionEnvelope(notification).action; + assert.ok(ready.type === ActionType.ChatToolCallReady); + assert.deepStrictEqual(ready.contributor, { kind: ToolCallContributorKind.Client, clientId }); + const rawInput = getInlineToolInput(ready.toolInput); + assert.ok(typeof rawInput === 'string'); + const input: Record = JSON.parse(rawInput); + context.client.dispatch({ + channel: chat, clientSeq: nextLiveToolSequence(), + action: { type: ActionType.ChatToolCallComplete, turnId, toolCallId: ready.toolCallId, result }, + }); + return input; + })(), + ]); + return input; + } + + test('regression coverage: client tools added after materialization execute on the next turn', async function () { + this.timeout(180_000); + await withLiveTools('live-tools-added', async session => { + await publishTools(session.sessionUri, session.clientId, [probeDefinition('added_probe')]); + const input = await executeClientProbe(buildDefaultChatUri(session.sessionUri), session.clientId, 'added_probe', 'added-probe', + 'Call added_probe exactly once and report its result.'); + assert.deepStrictEqual(input, {}); + }); + }); + + test('regression coverage: client tool schema replacement reaches a live provider session', async function () { + this.timeout(180_000); + await withLiveTools('live-tools-schema', async session => { + await publishTools(session.sessionUri, session.clientId, [probeDefinition('schema_probe', 'oldField')]); + assert.deepStrictEqual(await executeClientProbe(buildDefaultChatUri(session.sessionUri), session.clientId, 'schema_probe', 'schema-before', + 'Call schema_probe exactly once with oldField set to before and report its result.'), { oldField: 'before' }); + await publishTools(session.sessionUri, session.clientId, [probeDefinition('schema_probe', 'newField')]); + assert.deepStrictEqual(await executeClientProbe(buildDefaultChatUri(session.sessionUri), session.clientId, 'schema_probe', 'schema-after', + 'Call schema_probe exactly once with newField set to after. Use the current schema, not the previous one.'), { newField: 'after' }); + }); + }); + + test('regression coverage: client tool description replacement reaches the next model request', async function () { + this.timeout(180_000); + await withLiveTools('live-tools-description', async session => { + await publishTools(session.sessionUri, session.clientId, [probeDefinition('description_probe', undefined, 'DESCRIPTION_BEFORE')]); + await executeClientProbe(buildDefaultChatUri(session.sessionUri), session.clientId, 'description_probe', 'description-before', + 'Call description_probe exactly once and report its result.'); + const previous = context.observedModelRequestBodies.length; + await publishTools(session.sessionUri, session.clientId, [probeDefinition('description_probe', undefined, 'DESCRIPTION_AFTER')]); + await executeClientProbe(buildDefaultChatUri(session.sessionUri), session.clientId, 'description_probe', 'description-after', + 'Call description_probe exactly once using its current definition.'); + assert.ok(context.observedModelRequestBodies.slice(previous).some(body => body.includes('DESCRIPTION_AFTER'))); + }); + }); + + test('regression coverage: removing an obsolete client tool leaves its replacement executable', async function () { + this.timeout(180_000); + await withLiveTools('live-tools-removal', async session => { + await publishTools(session.sessionUri, session.clientId, [probeDefinition('obsolete_probe')]); + await executeClientProbe(buildDefaultChatUri(session.sessionUri), session.clientId, 'obsolete_probe', 'obsolete-before', + 'Call obsolete_probe exactly once and report its result.'); + const previousRequests = context.observedModelRequestBodies.length; + await publishTools(session.sessionUri, session.clientId, [probeDefinition('replacement_probe')]); + await executeClientProbe(buildDefaultChatUri(session.sessionUri), session.clientId, 'replacement_probe', 'replacement-after', + 'Call replacement_probe exactly once. The obsolete_probe tool has been removed; do not call it.'); + const request: { + tools: { name: string }[]; + messages: { role: string; content: string | { type: string; tool?: { type: string; name?: string; definition?: { name: string } } }[] }[]; + } = JSON.parse(context.observedModelRequestBodies[previousRequests]); + assert.ok(Array.isArray(request.tools)); + assert.ok(Array.isArray(request.messages)); + const effectiveNames = new Set(request.tools.map(tool => tool.name)); + for (const message of request.messages) { + if (message.role !== 'system' || !Array.isArray(message.content)) { + continue; + } + for (const block of message.content) { + if (block.type === 'tool_removal' && block.tool?.type === 'tool_reference' && block.tool.name) { + effectiveNames.delete(block.tool.name); + } else if (block.type === 'tool_addition' && block.tool?.type === 'tool_definition' && block.tool.definition) { + effectiveNames.add(block.tool.definition.name); + } + } + } + assert.deepStrictEqual({ + replacement: effectiveNames.has('replacement_probe'), + obsolete: effectiveNames.has('obsolete_probe'), + }, { replacement: true, obsolete: false }); + assert.deepStrictEqual(context.client.receivedNotifications(notification => + isActionNotification(notification, ActionType.ChatToolCallStart)) + .map(notification => getActionEnvelope(notification).action) + .flatMap(action => action.type === ActionType.ChatToolCallStart && action.turnId === 'replacement-after' ? [action.toolName] : []), + ['replacement_probe']); + }); + }); + + for (const variant of ['empty', 'failed', 'multiple text parts'] as const) { + test(`regression coverage: client tool ${variant} results permit a subsequent successful call`, async function () { + this.timeout(180_000); + await withLiveTools(`live-tools-${variant.replaceAll(' ', '-')}`, async session => { + await publishTools(session.sessionUri, session.clientId, [probeDefinition('result_probe')]); + const result: ToolCallResult = { + success: variant !== 'failed', + pastTenseMessage: 'Completed result probe', + content: variant === 'multiple text parts' + ? [{ type: ToolResultContentType.Text, text: 'LEFT_RESULT' }, { type: ToolResultContentType.Text, text: 'RIGHT_RESULT' }] + : [{ type: ToolResultContentType.Text, text: variant === 'empty' ? '' : 'EXPECTED_PROBE_FAILURE' }], + }; + await executeClientProbe(buildDefaultChatUri(session.sessionUri), session.clientId, 'result_probe', 'result-first', + 'Call result_probe exactly once. If it fails or is empty, stop and report that without retrying.', result); + if (variant === 'multiple text parts') { + assert.ok(context.observedModelRequestBodies.some(body => body.includes('LEFT_RESULT') && body.includes('RIGHT_RESULT'))); + } else if (variant === 'failed') { + assert.ok(context.observedModelRequestBodies.some(body => body.includes('EXPECTED_PROBE_FAILURE'))); + } + await executeClientProbe(buildDefaultChatUri(session.sessionUri), session.clientId, 'result_probe', 'result-followup', + 'Call result_probe exactly once again and report its new result.'); + }); + }); + } + + test('regression coverage: a new peer chat receives the current client tool definitions', async function () { + this.timeout(180_000); + await withLiveTools('live-tools-peer', async session => { + await publishTools(session.sessionUri, session.clientId, [probeDefinition('peer_probe', 'label')]); + const chat = buildChatUri(session.sessionUri, 'tools-peer'); + await context.client.call('createChat', { channel: session.sessionUri, chat }); + await context.client.call('subscribe', { channel: chat }); + assert.deepStrictEqual(await executeClientProbe(chat, session.clientId, 'peer_probe', 'peer-probe', + 'Call peer_probe exactly once with label set to peer.'), { label: 'peer' }); + }); + }); + + test('regression coverage: live client tool replacement reaches both materialized chats', async function () { + this.timeout(240_000); + await withLiveTools('live-tools-materialized-peer', async session => { + const defaultChat = buildDefaultChatUri(session.sessionUri); + const peer = buildChatUri(session.sessionUri, 'materialized-peer'); + await context.client.call('createChat', { channel: session.sessionUri, chat: peer }); + await context.client.call('subscribe', { channel: peer }); + await publishTools(session.sessionUri, session.clientId, [probeDefinition('shared_probe', 'oldLabel')]); + await executeClientProbe(defaultChat, session.clientId, 'shared_probe', 'default-old', 'Call shared_probe exactly once with oldLabel set to default.'); + await executeClientProbe(peer, session.clientId, 'shared_probe', 'peer-old', 'Call shared_probe exactly once with oldLabel set to peer.'); + await publishTools(session.sessionUri, session.clientId, [probeDefinition('shared_probe', 'newLabel')]); + assert.deepStrictEqual({ + defaultInput: await executeClientProbe(defaultChat, session.clientId, 'shared_probe', 'default-new', 'Call shared_probe exactly once with newLabel set to default. Use its current schema.'), + peerInput: await executeClientProbe(peer, session.clientId, 'shared_probe', 'peer-new', 'Call shared_probe exactly once with newLabel set to peer. Use its current schema.'), + }, { defaultInput: { newLabel: 'default' }, peerInput: { newLabel: 'peer' } }); + }); + }); + + test('regression coverage: cold resume binds client tool execution to the current client', async function () { + this.timeout(240_000); + await withLiveTools('live-tools-resume', async session => { + await publishTools(session.sessionUri, session.clientId, [probeDefinition('resume_probe')]); + await executeClientProbe(buildDefaultChatUri(session.sessionUri), session.clientId, 'resume_probe', 'probe-before-restart', + 'Call resume_probe exactly once and report its result.'); + await context.restartServer(); + session.clientId = 'live-tools-resumed-client'; + await context.client.call('initialize', { channel: ROOT_STATE_URI, protocolVersions: [PROTOCOL_VERSION], clientId: session.clientId }); + await context.client.call('authenticate', { channel: ROOT_STATE_URI, resource: 'https://api.github.com', token: resolveGitHubToken() }); + await context.client.call('subscribe', { channel: session.sessionUri }); + await context.client.call('subscribe', { channel: buildDefaultChatUri(session.sessionUri) }); + await publishTools(session.sessionUri, session.clientId, [probeDefinition('resume_probe')]); + await executeClientProbe(buildDefaultChatUri(session.sessionUri), session.clientId, 'resume_probe', 'probe-after-restart', + 'Call resume_probe exactly once again and report its result.'); + }); + }); + test('runtime tools: compacted shell output preserves the complete original', async function () { this.timeout(180_000); const { sessionUri, workspace } = await createSession('shell-compaction'); diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/mcpSideChannelSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/mcpSideChannelSuite.ts index 40d2957030d30c..ebc138f5f85723 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/mcpSideChannelSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/mcpSideChannelSuite.ts @@ -7,6 +7,7 @@ import assert from 'assert'; import { mkdtempSync, readFileSync, writeFileSync } from 'fs'; import { createRequire } from 'module'; import { tmpdir } from 'os'; +import type { CallToolResult as IToolResult, ListToolsResult } from '@modelcontextprotocol/sdk/types.js'; import { retry } from '../../../../../../base/common/async.js'; import { join } from '../../../../../../base/common/path.js'; import { URI } from '../../../../../../base/common/uri.js'; @@ -18,27 +19,104 @@ import { buildDefaultChatUri, CustomizationType, ROOT_STATE_URI, type McpServerC import { McpServerStatus } from '../../../../common/state/protocol/state.js'; import { JsonRpcErrorCodes, ProtocolError } from '../../../../common/state/sessionProtocol.js'; import { createRealSession, driveTurnToCompletion } from '../harness/agentHostE2ETestHarness.js'; +import { assertExpectedFailure } from '../harness/expectedFailure.js'; import { getActionEnvelope, isActionNotification } from '../../serverIntegrationTestHelpers.js'; import type { IAgentHostE2ETestContext } from './e2eTestContext.js'; const nodeRequire = createRequire(import.meta.url); - -interface IToolResult { - readonly content: readonly { readonly type: string; readonly text?: string }[]; - readonly isError?: boolean; -} +const { CallToolResultSchema }: Pick = nodeRequire('@modelcontextprotocol/sdk/types.js'); interface IResourceResult { readonly contents: readonly { readonly uri: string; readonly mimeType?: string; readonly text?: string; readonly blob?: string }[]; } +interface IMcpRequest { + readonly name?: string; + readonly arguments?: object; + readonly uri?: string; +} + +interface ICatalogListing extends ListToolsResult { + readonly revision: number; +} + interface IMcpSession { readonly session: string; readonly channel: string; readonly calls: string; + readonly reads: string; + readonly listings: string; readonly server: McpServerCustomization; } +const structuredToolResult: IToolResult = { + content: [{ type: 'text', text: 'STRUCTURED_RESULT' }], + structuredContent: { total: 12, rows: [{ enabled: false, value: 0 }, { enabled: true, value: null }], labels: ['first', 'second'] }, + _meta: { 'side-channel/view': { selected: 'second', hidden: true } }, +}; + +const mixedToolResult: IToolResult = { + content: [ + { type: 'text', text: 'MIXED_RESULT', annotations: { audience: ['user'], priority: 0.5 } }, + { type: 'image', mimeType: 'image/png', data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aV1sAAAAASUVORK5CYII=' }, + { type: 'resource', resource: { uri: 'data://side-channel/embedded/text', mimeType: 'text/plain', text: 'EMBEDDED_TEXT' } }, + { type: 'resource', resource: { uri: 'data://side-channel/embedded/blob', mimeType: 'application/octet-stream', blob: 'AAEC//4=' } }, + ], +}; + +const linkedToolResult: IToolResult = { + content: [ + { type: 'text', text: 'Read the linked report' }, + { type: 'resource_link', uri: 'data://side-channel/report', name: 'Report', mimeType: 'application/json', description: 'The calculated report' }, + ], +}; + +const mixedResourceResult: IResourceResult = { + contents: [ + { uri: 'data://side-channel/mixed/text', mimeType: 'text/plain', text: 'FIRST_RESOURCE' }, + { uri: 'data://side-channel/mixed/blob', mimeType: 'application/octet-stream', blob: 'AAEC//4=' }, + { uri: 'data://side-channel/mixed/json', mimeType: 'application/json', text: '{"last":true}' }, + ], +}; + +const echoTool: ListToolsResult['tools'][number] = { + name: 'echo', + description: 'Echoes structured input', + inputSchema: { type: 'object', properties: { tag: { type: 'string' }, nested: { type: 'object' } }, required: ['tag'] }, + _meta: { ui: { resourceUri: 'ui://side-channel/view' } }, + annotations: { readOnlyHint: true, openWorldHint: false }, +}; + +const sumTool: ListToolsResult['tools'][number] = { + name: 'sum', + description: 'Adds integer values', + inputSchema: { type: 'object', properties: { values: { type: 'array', items: { type: 'integer' } } }, required: ['values'] }, + outputSchema: { type: 'object', properties: { total: { type: 'integer' } }, required: ['total'] }, + annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, + _meta: { ui: { resourceUri: 'ui://side-channel/view' } }, +}; + +const refreshedInputSchema: ListToolsResult['tools'][number]['inputSchema'] = { + type: 'object', + properties: { tag: { type: 'string' }, nested: { $ref: '#/$defs/details' } }, + required: ['tag', 'nested'], + $defs: { + details: { + type: 'object', + properties: { count: { type: 'integer', minimum: 0 }, labels: { type: 'array', items: { type: 'string' } } }, + required: ['count'], + }, + }, +}; + +const refreshedOutputSchema: ListToolsResult['tools'][number]['outputSchema'] = { + type: 'object', properties: { tag: { type: 'string' }, nested: { type: 'object' } }, required: ['tag', 'nested'], +}; + +function readJsonLines(path: string): T[] { + return readFileSync(path, 'utf8').split('\n').filter(line => line.length > 0).map(line => JSON.parse(line)); +} + export function defineMcpSideChannelTests(context: IAgentHostE2ETestContext): void { if (context.tier !== 'parity' || context.config.provider === 'claude') { return; @@ -68,28 +146,61 @@ export function defineMcpSideChannelTests(context: IAgentHostE2ETestContext): vo const workspace = mkdtempSync(join(tmpdir(), 'ahp-mcp-channel-')); context.tempDirs.push(workspace); const calls = join(workspace, 'calls.jsonl'); + const reads = join(workspace, 'reads.jsonl'); + const listings = join(workspace, 'listings.jsonl'); writeFileSync(calls, ''); + writeFileSync(reads, ''); + writeFileSync(listings, ''); const script = join(workspace, 'server.cjs'); writeFileSync(script, [ `const { appendFileSync } = require('fs');`, `const { Server } = require(${JSON.stringify(nodeRequire.resolve('@modelcontextprotocol/sdk/server/index.js'))});`, `const { StdioServerTransport } = require(${JSON.stringify(nodeRequire.resolve('@modelcontextprotocol/sdk/server/stdio.js'))});`, `const { ListToolsRequestSchema, CallToolRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ReadResourceRequestSchema, McpError, ErrorCode } = require(${JSON.stringify(nodeRequire.resolve('@modelcontextprotocol/sdk/types.js'))});`, - 'const server = new Server({ name: "side-channel", version: "1.0.0" }, { capabilities: { tools: {}, resources: {} } });', - 'server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [{', - ' name: "echo", description: "Echoes structured input", inputSchema: { type: "object", properties: { tag: { type: "string" }, nested: { type: "object" } }, required: ["tag"] },', - ' _meta: { ui: { resourceUri: "ui://side-channel/view" } }, annotations: { readOnlyHint: true, openWorldHint: false }', - '}] }));', + 'const server = new Server({ name: "side-channel", version: "1.0.0" }, { capabilities: { tools: { listChanged: true }, resources: {} } });', + `const echoTool = ${JSON.stringify(echoTool)};`, + 'let tools = [echoTool];', + 'let revision = 0;', + 'let transientReads = 0;', + 'server.setRequestHandler(ListToolsRequestSchema, async () => {', + ` appendFileSync(${JSON.stringify(listings)}, JSON.stringify({ revision, tools }) + "\\n");`, + ' return { tools };', + '});', 'server.setRequestHandler(CallToolRequestSchema, async request => {', ` appendFileSync(${JSON.stringify(calls)}, JSON.stringify(request.params) + "\\n");`, + ' if (request.params.name === "sum") {', + ' const total = request.params.arguments.values.reduce((sum, value) => sum + value, 0);', + ' return { content: [{ type: "text", text: String(total) }], structuredContent: { total } };', + ' }', + ' const tag = request.params.arguments.tag;', + ' if (tag === "catalog-add" || tag === "catalog-remove" || tag === "catalog-schema") {', + ` if (tag === "catalog-add") { tools = [echoTool, ${JSON.stringify(sumTool)}]; }`, + ' if (tag === "catalog-remove") { tools = [echoTool]; }', + ` if (tag === "catalog-schema") { echoTool.inputSchema = ${JSON.stringify(refreshedInputSchema)}; echoTool.outputSchema = ${JSON.stringify(refreshedOutputSchema)}; }`, + ' revision++;', + ' await server.sendToolListChanged();', + ' return { content: [{ type: "text", text: JSON.stringify({ revision }) }] };', + ' }', + ` if (tag === "structured-result") { return ${JSON.stringify(structuredToolResult)}; }`, + ` if (tag === "mixed-result") { return ${JSON.stringify(mixedToolResult)}; }`, + ` if (tag === "resource-link") { return ${JSON.stringify(linkedToolResult)}; }`, + ' if (tag === "empty-result") { return { content: [], isError: false }; }', ' if (request.params.arguments.tag === "error") { return { isError: true, content: [{ type: "text", text: "EXPECTED_TOOL_ERROR" }] }; }', ' if (request.params.arguments.tag === "rpc-error") { throw new McpError(ErrorCode.InvalidParams, "EXPECTED_RPC_ERROR"); }', + ' if (echoTool.outputSchema) { return { content: [{ type: "text", text: JSON.stringify(request.params.arguments) }], structuredContent: request.params.arguments }; }', ' return { content: [{ type: "text", text: JSON.stringify(request.params.arguments) }] };', '});', 'server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [{ uri: "ui://side-channel/view", name: "View", mimeType: "text/html;profile=mcp-app" }] }));', 'server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({ resourceTemplates: [{ uriTemplate: "data://side-channel/{name}", name: "Data" }] }));', 'server.setRequestHandler(ReadResourceRequestSchema, async request => {', + ` appendFileSync(${JSON.stringify(reads)}, JSON.stringify(request.params) + "\\n");`, ' if (request.params.uri === "data://side-channel/missing") { throw new McpError(ErrorCode.InvalidParams, "EXPECTED_MISSING_RESOURCE"); }', + ` if (request.params.uri === "data://side-channel/mixed") { return ${JSON.stringify(mixedResourceResult)}; }`, + ' if (request.params.uri === "data://side-channel/report") { return { contents: [{ uri: request.params.uri, mimeType: "application/json", text: \'{"total":12}\' }] }; }', + ' if (request.params.uri === "data://side-channel/transient") {', + ' if (++transientReads === 1) { throw new McpError(ErrorCode.InternalError, "EXPECTED_TRANSIENT_RESOURCE_ERROR"); }', + ' return { contents: [{ uri: request.params.uri, mimeType: "text/plain", text: "RECOVERED_RESOURCE" }] };', + ' }', ' return { contents: [request.params.uri === "data://side-channel/binary"', ' ? { uri: request.params.uri, mimeType: "application/octet-stream", blob: "AAEC//4=" }', ' : { uri: request.params.uri, mimeType: "text/html;profile=mcp-app", text: "SIDEBAND_RESOURCE" }] };', @@ -111,7 +222,7 @@ export function defineMcpSideChannelTests(context: IAgentHostE2ETestContext): vo return state; }, 100, 200); assert.ok(server.channel); - await run({ session, channel: server.channel, server, calls }); + await run({ session, channel: server.channel, server, calls, reads, listings }); } finally { if (previous !== undefined) { await setRootServers(previous); @@ -119,13 +230,191 @@ export function defineMcpSideChannelTests(context: IAgentHostE2ETestContext): vo } } - function scenario(title: string, run: (session: IMcpSession) => Promise): void { - test(`MCP side channel: ${title}`, async function () { + function scenario(title: string, run: (session: IMcpSession) => Promise, enabled = true): void { + (enabled ? test : test.skip)(`MCP side channel: ${title}`, async function () { this.timeout(180_000); await withServer(title, run); }); } + function regressionScenario(title: string, run: (session: IMcpSession) => Promise, enabled = true): void { + scenario(`regression coverage: ${title}`, async session => { + const before = context.observedModelRequestBodies.length; + await run(session); + assert.strictEqual(context.observedModelRequestBodies.length, before, 'Side-channel operations must not request another model turn'); + }, enabled); + } + + async function changeCatalog(channel: string, tag: string, expectedTools: ListToolsResult['tools']): Promise<{ changed: IToolResult; catalog: ListToolsResult }> { + const changed = await context.client.call('tools/call', { channel, name: 'echo', arguments: { tag } }); + // Native list changes do not guarantee an AHP push; tools/list reads the live server. + const catalog = await retry(async () => { + const result = await context.client.call('tools/list', { channel }); + assert.deepStrictEqual(result.tools, expectedTools); + return result; + }, 100, 50); + return { changed, catalog }; + } + + regressionScenario('preserves structured tool results and private result metadata', async ({ channel, calls }) => { + const result = await context.client.call('tools/call', { channel, name: 'echo', arguments: { tag: 'structured-result' } }); + assert.deepStrictEqual({ + content: result.content, structuredContent: result.structuredContent, meta: result._meta, error: result.isError === true, + calls: readJsonLines(calls).map(({ name, arguments: args }) => ({ name, arguments: args })), + }, { + content: structuredToolResult.content, structuredContent: structuredToolResult.structuredContent, meta: structuredToolResult._meta, error: false, + calls: [{ name: 'echo', arguments: { tag: 'structured-result' } }], + }); + }); + + // Skip Codex recording so the known decoding failure cannot replace its complete fixture. + regressionScenario('preserves ordered text image and embedded resource tool content', async ({ channel, calls }) => { + CallToolResultSchema.parse(mixedToolResult); + const [outcome] = await Promise.allSettled([ + context.client.call('tools/call', { channel, name: 'echo', arguments: { tag: 'mixed-result' } }), + ]); + assert.deepStrictEqual( + readJsonLines(calls).map(({ name, arguments: args }) => ({ name, arguments: args })), + [{ name: 'echo', arguments: { tag: 'mixed-result' } }], + ); + const assertResult = () => { + if (outcome.status === 'rejected') { + throw outcome.reason; + } + assert.deepStrictEqual({ + content: outcome.value.content, error: outcome.value.isError === true, + }, { content: mixedToolResult.content, error: false }); + }; + if (context.config.provider === 'codex') { + await assertExpectedFailure( + 'Codex MCP side-channel mixed-content response decoding', + // AHP includes the remote stack in error.message; permit only stack-frame suffixes. + /^JsonRpcError: tool call failed for `side_channel\/echo`: Unexpected response type(?:\r?\n[ \t]+at [^\r\n]+)*$/, + assertResult, + ); + } else { + assertResult(); + } + }, context.config.provider !== 'codex' || !(process.env['AGENT_HOST_REPLAY_RECORD'] === '1' || process.env['AGENT_HOST_UPDATE_SNAPSHOTS'] === '1')); + + regressionScenario('returns resource links that can be read on the same advertised channel', async ({ channel, calls, reads }) => { + const result = await context.client.call('tools/call', { channel, name: 'echo', arguments: { tag: 'resource-link' } }); + const link = result.content.find(content => content.type === 'resource_link'); + assert.ok(link?.type === 'resource_link', 'The actual tool result must contain a resource link'); + const resource = await context.client.call('resources/read', { channel, uri: link.uri }); + assert.deepStrictEqual({ + content: result.content, resource: resource.contents, + calls: readJsonLines(calls).map(request => request.arguments), + reads: readJsonLines(reads).map(request => request.uri), + }, { + content: linkedToolResult.content, + resource: [{ uri: 'data://side-channel/report', mimeType: 'application/json', text: '{"total":12}' }], + calls: [{ tag: 'resource-link' }], reads: ['data://side-channel/report'], + }); + }); + + regressionScenario('preserves multiple resource contents with distinct URIs and encodings', async ({ channel, reads }) => { + const uri = 'data://side-channel/mixed'; + const result = await context.client.call('resources/read', { channel, uri }); + assert.deepStrictEqual({ + contents: result.contents, reads: readJsonLines(reads).map(request => request.uri), + }, { contents: mixedResourceResult.contents, reads: [uri] }); + }); + + regressionScenario('returns an empty successful tool result without fabricating text', async ({ channel, calls }) => { + const result = await context.client.call('tools/call', { channel, name: 'echo', arguments: { tag: 'empty-result' } }); + assert.deepStrictEqual({ + content: result.content, error: result.isError === true, + calls: readJsonLines(calls).map(request => request.arguments), + }, { content: [], error: false, calls: [{ tag: 'empty-result' }] }); + }); + + regressionScenario('isolates a concurrent server error from successful tool and resource responses', async ({ channel, calls, reads }) => { + const [failed, tool, resource] = await Promise.allSettled([ + context.client.call('tools/call', { channel, name: 'echo', arguments: { tag: 'rpc-error' } }), + context.client.call('tools/call', { channel, name: 'echo', arguments: { tag: 'concurrent-healthy' } }), + context.client.call('resources/read', { channel, uri: 'data://side-channel/binary' }), + ]); + assert.ok(failed.status === 'rejected' && failed.reason instanceof Error && /EXPECTED_RPC_ERROR/.test(failed.reason.message)); + assert.ok(tool.status === 'fulfilled' && resource.status === 'fulfilled', 'Independent successful MCP requests must resolve'); + const recovered = await context.client.call('tools/call', { channel, name: 'echo', arguments: { tag: 'after-concurrent-error' } }); + assert.deepStrictEqual({ + tool: tool.value.content, resource: resource.value.contents, recovered: recovered.content, + calls: readJsonLines<{ arguments: { tag: string } }>(calls).map(request => request.arguments.tag).sort(), + reads: readJsonLines(reads).map(request => request.uri), + }, { + tool: [{ type: 'text', text: '{"tag":"concurrent-healthy"}' }], + resource: [{ uri: 'data://side-channel/binary', mimeType: 'application/octet-stream', blob: 'AAEC//4=' }], + recovered: [{ type: 'text', text: '{"tag":"after-concurrent-error"}' }], + calls: ['after-concurrent-error', 'concurrent-healthy', 'rpc-error'], + reads: ['data://side-channel/binary'], + }); + }); + + regressionScenario('retries the same resource after a transient real server error', async ({ channel, reads }) => { + const uri = 'data://side-channel/transient'; + await assert.rejects(context.client.call('resources/read', { channel, uri }), /EXPECTED_TRANSIENT_RESOURCE_ERROR/); + const result = await context.client.call('resources/read', { channel, uri }); + assert.deepStrictEqual({ + contents: result.contents, reads: readJsonLines(reads).map(request => request.uri), + }, { + contents: [{ uri, mimeType: 'text/plain', text: 'RECOVERED_RESOURCE' }], reads: [uri, uri], + }); + }); + + if (context.config.provider === 'copilotcli') { + regressionScenario('refreshes the tool catalog after a real server adds a callable tool', async ({ channel, calls, listings }) => { + const { changed, catalog } = await changeCatalog(channel, 'catalog-add', [echoTool, sumTool]); + const result = await context.client.call('tools/call', { channel, name: 'sum', arguments: { values: [3, 4, 5] } }); + assert.deepStrictEqual({ + changed: changed.content, names: catalog.tools.map(tool => tool.name), added: catalog.tools.find(tool => tool.name === 'sum'), + content: result.content, structuredContent: result.structuredContent, + calls: readJsonLines(calls).map(({ name, arguments: args }) => ({ name, arguments: args })), + listedNewCatalog: readJsonLines(listings).some(listing => listing.revision === 1 && listing.tools.some(tool => tool.name === 'sum')), + }, { + changed: [{ type: 'text', text: '{"revision":1}' }], names: ['echo', 'sum'], added: sumTool, + content: [{ type: 'text', text: '12' }], structuredContent: { total: 12 }, + calls: [{ name: 'echo', arguments: { tag: 'catalog-add' } }, { name: 'sum', arguments: { values: [3, 4, 5] } }], + listedNewCatalog: true, + }); + }); + + regressionScenario('refreshes schemas for an existing tool without a name change', async ({ channel, calls, listings }) => { + const { changed, catalog } = await changeCatalog(channel, 'catalog-schema', [ + { ...echoTool, inputSchema: refreshedInputSchema, outputSchema: refreshedOutputSchema }, + ]); + const args = { tag: 'schema-result', nested: { count: 2, labels: ['new', 'schema'] } }; + const result = await context.client.call('tools/call', { channel, name: 'echo', arguments: args }); + assert.deepStrictEqual({ + changed: changed.content, + tools: catalog.tools.map(({ name, inputSchema, outputSchema }) => ({ name, inputSchema, outputSchema })), + content: result.content, structuredContent: result.structuredContent, + calls: readJsonLines(calls).map(request => request.arguments), + listedNewSchema: readJsonLines(listings).some(listing => listing.revision === 1 && listing.tools[0]?.outputSchema !== undefined), + }, { + changed: [{ type: 'text', text: '{"revision":1}' }], + tools: [{ name: 'echo', inputSchema: refreshedInputSchema, outputSchema: refreshedOutputSchema }], + content: [{ type: 'text', text: JSON.stringify(args) }], structuredContent: args, + calls: [{ tag: 'catalog-schema' }, args], listedNewSchema: true, + }); + }); + + regressionScenario('removes a tool from a refreshed catalog while retaining healthy calls', async ({ channel, calls, listings }) => { + const { catalog: added } = await changeCatalog(channel, 'catalog-add', [echoTool, sumTool]); + const { changed, catalog: removed } = await changeCatalog(channel, 'catalog-remove', [echoTool]); + const result = await context.client.call('tools/call', { channel, name: 'echo', arguments: { tag: 'after-removal' } }); + assert.deepStrictEqual({ + added: added.tools.map(tool => tool.name), removed: removed.tools.map(tool => tool.name), changed: changed.content, + content: result.content, calls: readJsonLines(calls).map(request => request.arguments), + listedRemovedCatalog: readJsonLines(listings).some(listing => listing.revision === 2 && listing.tools.length === 1 && listing.tools[0].name === 'echo'), + }, { + added: ['echo', 'sum'], removed: ['echo'], changed: [{ type: 'text', text: '{"revision":2}' }], + content: [{ type: 'text', text: '{"tag":"after-removal"}' }], + calls: [{ tag: 'catalog-add' }, { tag: 'catalog-remove' }, { tag: 'after-removal' }], listedRemovedCatalog: true, + }); + }); + } + scenario('lists the real server tool schema without another model request', async ({ channel, calls }) => { const before = context.observedModelRequestBodies.length; const result = await context.client.call<{ tools: readonly { name: string; inputSchema: object }[] }>('tools/list', { channel }); diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/serverToolsSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/serverToolsSuite.ts index 50578a0413ca32..f2df1f8b1e39a0 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/serverToolsSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/serverToolsSuite.ts @@ -10,7 +10,7 @@ import { retry } from '../../../../../../base/common/async.js'; import { join } from '../../../../../../base/common/path.js'; import { URI } from '../../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; -import { AgentHostArtifactToolsConfigKey } from '../../../../common/agentHostSchema.js'; +import { AgentHostActiveAgentTitleGenerationConfigKey, AgentHostArtifactToolsConfigKey } from '../../../../common/agentHostSchema.js'; import { FEEDBACK_ANNOTATION_META_KEY, type IFeedbackAnnotationMeta } from '../../../../common/meta/agentFeedbackAnnotations.js'; import { buildAnnotationsUri } from '../../../../common/annotationsUri.js'; import { buildOpenSessionLinkUri } from '../../../../common/openSessionLink.js'; @@ -59,20 +59,25 @@ interface ISeedFeedbackOptions { const feedbackToolNames = ['addComment', 'listComments', 'replyToComment', 'deleteComments', 'resolveComments', 'viewUnreviewedComments'] as const; const feedbackResourceUri = 'untitled://server-tools/reviewed.ts'; -const sessionToolNames = [ - SessionServerToolName.ListSessions, - SessionServerToolName.GetCurrentSession, - SessionServerToolName.CreateSession, - SessionServerToolName.RenameChat, - SessionServerToolName.SendMessage, - SessionServerToolName.GetSessionContext, - SessionServerToolName.DeleteSession, -] as const; +function getSessionToolNames(supportsWorkspaceChange: boolean): readonly SessionServerToolName[] { + return [ + SessionServerToolName.ListSessions, + SessionServerToolName.GetCurrentSession, + ...(supportsWorkspaceChange ? [SessionServerToolName.SetWorkspace] : []), + SessionServerToolName.CreateSession, + SessionServerToolName.SendMessage, + SessionServerToolName.GetSessionContext, + SessionServerToolName.DeleteSession, + ...(supportsWorkspaceChange ? [SessionServerToolName.IsolateSession] : []), + ]; +} export function defineServerToolsTests(context: IAgentHostE2ETestContext): void { const { config, createdSessions, tempDirs } = context; // Claude omits the prior server-tool input from detailed session context. const supportsFullSessionContext = config.provider !== 'claude'; + // Claude cannot move a chat to another workspace. + const supportsWorkspaceChange = config.provider !== 'claude'; // Claude reports success but leaves the target listed. const supportsCrossSessionDelete = config.provider !== 'claude'; // Claude starts another turn instead of rejecting a message to the current chat. @@ -281,42 +286,48 @@ export function defineServerToolsTests(context: IAgentHostE2ETestContext): void } return state.serverTools.map(tool => tool.name); }, 100, 30); - assert.deepStrictEqual(toolNames, [...feedbackToolNames, ...sessionToolNames]); + assert.deepStrictEqual(toolNames, [...feedbackToolNames, ...getSessionToolNames(supportsWorkspaceChange)]); }); serverToolTest('server tool: rename_chat renames the chat it runs in', async function () { - const session = await createSession('rename-chat'); - await driveTurnToCompletion( - context.client, - session.sessionUri, - 'turn-rename-chat-seed', - '/rename Seeded Chat', - reserveClientSequenceBlock(), - { expectUnread: false }, - ); - const { tool } = await driveServerTool( - session, - 'turn-rename-chat', - 'Call the rename_chat tool exactly once with title "Coverage audit", then reply with exactly "renamed".', - SessionServerToolName.RenameChat, - ); - const renamed = await retry(async () => { - const sessionTitle = (await sessionState(session.sessionUri)).title; - const chatTitle = (await chatState(session.chatUri)).title; - if (sessionTitle !== 'Coverage audit' || chatTitle !== 'Coverage audit') { - throw new Error('The chat rename has not completed'); - } - return { sessionTitle, chatTitle }; - }, 100, 100); + try { + const session = await createSession('rename-chat', false, () => setRootConfig({ + [AgentHostActiveAgentTitleGenerationConfigKey]: true, + })); + await driveTurnToCompletion( + context.client, + session.sessionUri, + 'turn-rename-chat-seed', + '/rename Seeded Chat', + reserveClientSequenceBlock(), + { expectUnread: false }, + ); + const { tool } = await driveServerTool( + session, + 'turn-rename-chat', + 'Call the rename_chat tool exactly once with title "Coverage audit", then reply with exactly "renamed".', + SessionServerToolName.RenameChat, + ); + const renamed = await retry(async () => { + const sessionTitle = (await sessionState(session.sessionUri)).title; + const chatTitle = (await chatState(session.chatUri)).title; + if (sessionTitle !== 'Coverage audit' || chatTitle !== 'Coverage audit') { + throw new Error('The chat rename has not completed'); + } + return { sessionTitle, chatTitle }; + }, 100, 100); - assert.deepStrictEqual({ - succeeded: tool.completion.result.success, - ...renamed, - }, { - succeeded: true, - sessionTitle: 'Coverage audit', - chatTitle: 'Coverage audit', - }); + assert.deepStrictEqual({ + succeeded: tool.completion.result.success, + ...renamed, + }, { + succeeded: true, + sessionTitle: 'Coverage audit', + chatTitle: 'Coverage audit', + }); + } finally { + await setRootConfig({ [AgentHostActiveAgentTitleGenerationConfigKey]: false }); + } }); serverToolTest('server tool: add_artifact_or_reference records artifacts and references in one batch', async function () { diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts index 372be4a3ceb08d..2679258d83b622 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts @@ -12,13 +12,14 @@ import { URI } from '../../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; import { SessionConfigKey } from '../../../../common/sessionConfigKeys.js'; import { parseSessionDbUri } from '../../../../common/sessionDbUri.js'; -import type { ListSessionsResult, ResourceReadResult, SubscribeResult } from '../../../../common/state/protocol/commands.js'; +import { ChatSourceKind, type ListSessionsResult, type ResourceReadResult, type SubscribeResult } from '../../../../common/state/protocol/commands.js'; import { ContentEncoding } from '../../../../common/state/protocol/common/commands.js'; import type { SessionSummaryChangedParams } from '../../../../common/state/protocol/channels-root/notifications.js'; -import { ActionType, type ChatToolCallCompleteAction } from '../../../../common/state/sessionActions.js'; -import { buildChatUri, buildDefaultChatUri, MessageAttachmentKind, MessageKind, ROOT_STATE_URI, SessionStatus, ToolResultContentType, type ChatState, type SessionState, type ToolResultFileEditContent } from '../../../../common/state/sessionState.js'; +import { ActionType, type ChatToolCallCompleteAction, type ChatToolCallStartAction } from '../../../../common/state/sessionActions.js'; +import { buildChatUri, buildDefaultChatUri, MessageAttachmentKind, MessageKind, ResponsePartKind, ROOT_STATE_URI, SessionStatus, ToolCallStatus, ToolResultContentType, TurnState, type ChatState, type SessionState, type ToolResultFileEditContent } from '../../../../common/state/sessionState.js'; import { PROTOCOL_VERSION } from '../../../../common/state/protocol/version/registry.js'; -import { createRealSession, driveTurnToCompletion, driveTurnWithAttachmentsToCompletion, resolveGitHubToken } from '../harness/agentHostE2ETestHarness.js'; +import { createRealSession, driveChatTurnToCompletion, driveTurnToCompletion, driveTurnWithAttachmentsToCompletion, resolveGitHubToken } from '../harness/agentHostE2ETestHarness.js'; +import { summarizeAnthropicRequest, summarizeResponsesRequest } from '../harness/capiWireCodec.js'; import { fetchSessionWithChat, getActionEnvelope, getAgentHostE2ETestTimeout, isActionNotification } from '../../serverIntegrationTestHelpers.js'; import type { IAgentHostE2ETestContext } from './e2eTestContext.js'; import { GITHUB_COPILOT_PROTECTED_RESOURCE } from '../../../../common/agent.js'; @@ -372,4 +373,392 @@ export function defineSessionPersistenceTests(context: IAgentHostE2ETestContext) peerMessages: ['/rename Rehydrated Peer'], }); }); + + async function createPersistenceSession(prefix: string): Promise<{ sessionUri: string; chatUri: string; workspace: string }> { + const workspace = fs.realpathSync(fs.mkdtempSync(join(tmpdir(), `ahp-history-${prefix}-`))); + tempDirs.push(workspace); + // Keep Codex's protected mount directories stable across sibling-chat startup (KNOWN_ISSUES.md). + if (config.provider === 'codex' && context.isLinux) { + for (const directory of ['.git', '.agents', '.codex']) { + fs.mkdirSync(join(workspace, directory)); + } + } + const sessionUri = await createRealSession(context.client, config, `${prefix}-${config.provider}`, createdSessions, URI.file(workspace)); + return { sessionUri, chatUri: buildDefaultChatUri(sessionUri), workspace }; + } + + async function readChat(chat: string): Promise { + const result = await context.client.call('subscribe', { channel: chat }); + assert.ok(result.snapshot, 'A restored chat must have a snapshot'); + const state = result.snapshot.state as ChatState; + assert.strictEqual(state.activeTurn, undefined); + assert.ok(state.turns.every(turn => turn.state === TurnState.Complete)); + return state; + } + + async function createHistoryPeer(session: string, source?: { chat: string; turnId: string }): Promise { + const chat = buildChatUri(session, generateUuid()); + await context.client.call('createChat', { + channel: session, + chat, + ...(source ? { source: { kind: ChatSourceKind.Fork, ...source } } : {}), + }, 30_000); + await readChat(chat); + return chat; + } + + function completedToolHistory(state: ChatState): object[] { + return state.turns.map(turn => ({ + message: turn.message.text, + state: turn.state, + tools: turn.responseParts.flatMap(part => { + if (part.kind !== ResponsePartKind.ToolCall) { + return []; + } + assert.ok(part.toolCall.status === ToolCallStatus.Completed); + return [{ + name: part.toolCall.toolName, + status: part.toolCall.status, + success: part.toolCall.success, + }]; + }), + })); + } + + async function editInChat(chat: string, turnId: string, workspace: string, file: string, before: string, after: string, clientSeq: number, prompt = config.provider === 'claude' + ? `Read ${file} with Read, then use Edit exactly once to replace ${before} with ${after}. Do not search for files or run a shell command. Then reply exactly "EDIT_DONE".` + : `Use your file editing tool exactly once to replace ${before} with ${after} in ${file}. Do not inspect or search for files and do not run a shell command. Then reply exactly "EDIT_DONE".`): Promise { + await driveChatTurnToCompletion(context.client, chat, turnId, prompt, clientSeq); + const editNames = new Set(['edit', 'Edit', 'MultiEdit', 'Write', 'apply_patch', 'file_edit']); + const starts = context.client.receivedNotifications(n => + isActionNotification(n, 'chat/toolCallStart') && getActionEnvelope(n).channel === chat, + ).map(n => getActionEnvelope(n).action as ChatToolCallStartAction) + .filter(action => action.turnId === turnId && editNames.has(action.toolName)); + const completions = context.client.receivedNotifications(n => + isActionNotification(n, 'chat/toolCallComplete') && getActionEnvelope(n).channel === chat, + ).map(n => getActionEnvelope(n).action as ChatToolCallCompleteAction) + .filter(action => action.turnId === turnId && starts.some(start => start.toolCallId === action.toolCallId)); + assert.ok(completions.length > 0, 'The provider must execute a real file editing tool on the requested chat'); + assert.ok(completions.every(action => action.result.success), `Every file edit must complete successfully: ${JSON.stringify(completions.map(action => action.result))}`); + assert.strictEqual(fs.readFileSync(join(workspace, file), 'utf8'), `${after}\n`); + const state = await readChat(chat); + const turn = state.turns.at(-1); + assert.strictEqual(turn?.message.text, prompt); + assert.ok(turn?.responseParts.some(part => + part.kind === ResponsePartKind.ToolCall + && part.toolCall.status === ToolCallStatus.Completed + && part.toolCall.success + && completions.some(action => action.toolCallId === part.toolCall.toolCallId), + )); + return prompt; + } + + function lastRequestMessages(): readonly { role: string; content: string }[] { + const body = context.observedModelRequestBodies.at(-1); + assert.ok(body, 'The continuation must reach the real provider model boundary'); + const request = summarizeAnthropicRequest(body) ?? summarizeResponsesRequest(body); + assert.ok(request, 'Expected a supported provider model request'); + return request.messages.map(message => ({ + role: message.role, + content: typeof message.content === 'string' ? message.content : JSON.stringify(message.content), + })); + } + + function assertRequestContext(expected: readonly { role: string; text: string }[], excluded: readonly string[] = []): void { + const messages = lastRequestMessages(); + let nextIndex = 0; + for (const entry of expected) { + const index = messages.findIndex((message, index) => index >= nextIndex && message.role === entry.role && message.content.includes(entry.text)); + assert.ok(index >= nextIndex, `Missing ordered ${entry.role} context: ${entry.text}`); + nextIndex = index + 1; + } + for (const text of excluded) { + assert.ok(messages.every(message => !message.content.includes(text)), `Unrelated context leaked into the provider request: ${text}`); + } + } + + function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); + } + + function assertRequestToolPair(inputText: string): void { + const body = context.observedModelRequestBodies.at(-1); + assert.ok(body); + const request: unknown = JSON.parse(body); + assert.ok(isRecord(request)); + const blocks: Record[] = []; + if (Array.isArray(request.messages)) { + for (const message of request.messages) { + if (isRecord(message) && Array.isArray(message.content)) { + blocks.push(...message.content.filter(isRecord)); + } + } + } else { + assert.ok(Array.isArray(request.input)); + blocks.push(...request.input.filter(isRecord)); + } + const calls = blocks.filter(block => { + const input = JSON.stringify(block.input ?? block.arguments); + return (block.type === 'tool_use' || block.type === 'function_call' || block.type === 'custom_tool_call') + && typeof input === 'string' && input.includes(inputText); + }); + assert.ok(calls.length > 0, `Expected a persisted tool invocation containing ${inputText}`); + for (const call of calls) { + const id = call.type === 'tool_use' ? call.id : call.call_id; + assert.strictEqual(typeof id, 'string'); + const result = blocks.find(block => + (block.type === 'tool_result' && block.tool_use_id === id && block.is_error !== true && block.content !== undefined) + || ((block.type === 'function_call_output' || block.type === 'custom_tool_call_output') && block.call_id === id && block.output !== undefined), + ); + assert.ok(result, 'A completed persisted tool invocation must retain its matching result'); + assert.doesNotMatch(JSON.stringify(result.content ?? result.output), /Tool execution (?:was )?(?:aborted|interrupted)/i); + } + } + + // Reuse the documented Copilot/Windows tool-history restart limitation. + const restoredToolHistoryEnabled = !(context.isWindows && config.provider === 'copilotcli') || context.runKnownIssueTests; + // Claude's documented turn-anchor defect also prevents recording a provider-backed fork. + const forkHistoryEnabled = config.supportsChatFork && (config.supportsChatForkE2E || (RECORDING && context.runKnownIssueTests)); + const restoredForkHistoryEnabled = forkHistoryEnabled && peerChatPersistenceEnabled; + + test('regression coverage: cold resume retains ordered user and assistant context', async function () { + this.timeout(240_000); + const { sessionUri, chatUri, workspace } = await createPersistenceSession('ordered-context'); + const firstPrompt = 'Remember the first entry ORDERED_ALPHA. Reply exactly "ALPHA_READY".'; + const secondPrompt = 'Remember the second entry ORDERED_BETA without replacing the first. Reply exactly "BETA_READY".'; + const first = await driveChatTurnToCompletion(context.client, chatUri, 'ordered-first', firstPrompt, 1); + const second = await driveChatTurnToCompletion(context.client, chatUri, 'ordered-second', secondPrompt, 10); + assert.ok(first.responseText.trim() && second.responseText.trim()); + await restartAndInitialize('ordered-context-restored', workspace); + assert.deepStrictEqual((await readChat(chatUri)).turns.map(turn => turn.message.text), [firstPrompt, secondPrompt]); + const followup = 'Reply with both entries in the order I gave them. Do not use tools.'; + await driveChatTurnToCompletion(context.client, chatUri, 'ordered-followup', followup, 1); + assertRequestContext([ + { role: 'user', text: firstPrompt }, + { role: 'assistant', text: first.responseText.trim() }, + { role: 'user', text: secondPrompt }, + { role: 'assistant', text: second.responseText.trim() }, + { role: 'user', text: followup }, + ]); + assert.deepStrictEqual((await fetchSessionWithChat(context.client, sessionUri)).turns.map(turn => turn.message.text), [firstPrompt, secondPrompt, followup]); + }); + + test('regression coverage: identical prompts remain separate completed turns after resume', async function () { + this.timeout(240_000); + const { chatUri, workspace } = await createPersistenceSession('identical-prompts'); + const prompt = 'Reply exactly "REPEATED_READY". Do not use tools.'; + await driveChatTurnToCompletion(context.client, chatUri, 'identical-first', prompt, 1); + await driveChatTurnToCompletion(context.client, chatUri, 'identical-second', prompt, 10); + await restartAndInitialize('identical-prompts-restored', workspace); + const restored = await readChat(chatUri); + assert.deepStrictEqual({ + messages: restored.turns.map(turn => turn.message.text), + distinctTurns: new Set(restored.turns.map(turn => turn.id)).size, + }, { messages: [prompt, prompt], distinctTurns: 2 }); + await driveChatTurnToCompletion(context.client, chatUri, 'identical-followup', 'Reply exactly "CONTINUED". Do not use tools.', 1); + assertRequestContext([{ role: 'user', text: prompt }, { role: 'assistant', text: 'REPEATED_READY' }, { role: 'user', text: prompt }, { role: 'assistant', text: 'REPEATED_READY' }]); + assert.strictEqual(lastRequestMessages().filter(message => message.role === 'user' && message.content.includes(prompt)).length, 2); + }); + + (restoredToolHistoryEnabled ? test : test.skip)('regression coverage: completed file edit remains paired with its result after resume', async function () { + this.timeout(240_000); + const { chatUri, workspace } = await createPersistenceSession('completed-edit'); + fs.writeFileSync(join(workspace, 'paired.txt'), 'PAIR_BEFORE\n'); + const prompt = await editInChat(chatUri, 'paired-edit', workspace, 'paired.txt', 'PAIR_BEFORE', 'PAIR_AFTER', 1); + const before = completedToolHistory(await readChat(chatUri)); + await restartAndInitialize('completed-edit-restored', workspace); + assert.deepStrictEqual(completedToolHistory(await readChat(chatUri)), before); + await driveChatTurnToCompletion(context.client, chatUri, 'paired-followup', 'Reply exactly "HISTORY_READY". Do not use tools.', 1); + assertRequestContext([{ role: 'user', text: prompt }, { role: 'assistant', text: 'EDIT_DONE' }]); + assertRequestToolPair('PAIR_AFTER'); + assert.strictEqual(fs.readFileSync(join(workspace, 'paired.txt'), 'utf8'), 'PAIR_AFTER\n'); + }); + + (restoredToolHistoryEnabled ? test : test.skip)('regression coverage: successive file edits retain their turn ownership after restart', async function () { + this.timeout(240_000); + const { chatUri, workspace } = await createPersistenceSession('successive-edits'); + fs.writeFileSync(join(workspace, 'successive.txt'), 'EDIT_ZERO\n'); + const first = await editInChat(chatUri, 'successive-first', workspace, 'successive.txt', 'EDIT_ZERO', 'EDIT_ONE', 1); + const second = await editInChat(chatUri, 'successive-second', workspace, 'successive.txt', 'EDIT_ONE', 'EDIT_TWO', 10); + const before = completedToolHistory(await readChat(chatUri)); + await restartAndInitialize('successive-edits-restored', workspace); + const restored = await readChat(chatUri); + assert.deepStrictEqual(completedToolHistory(restored), before); + assert.deepStrictEqual(restored.turns.map(turn => turn.message.text), [first, second]); + const ids = restored.turns.flatMap(turn => turn.responseParts.flatMap(part => part.kind === ResponsePartKind.ToolCall ? [part.toolCall.toolCallId] : [])); + assert.strictEqual(new Set(ids).size, ids.length, 'A completed call must belong to exactly one restored turn'); + await driveChatTurnToCompletion(context.client, chatUri, 'successive-followup', 'Reply exactly "TWO_EDITS_READY". Do not use tools.', 1); + assertRequestToolPair('EDIT_ZERO'); + assertRequestToolPair('EDIT_TWO'); + }); + + (peerChatPersistenceEnabled ? test : test.skip)('regression coverage: peer tool history and subsequent edits stay on the restored peer', async function () { + this.timeout(240_000); + const { sessionUri, chatUri, workspace } = await createPersistenceSession('peer-tool-history'); + const defaultPrompt = 'Remember DEFAULT_HISTORY_ONLY. Reply exactly "DEFAULT_READY".'; + await driveChatTurnToCompletion(context.client, chatUri, 'peer-tools-default', defaultPrompt, 1); + const peer = await createHistoryPeer(sessionUri); + fs.writeFileSync(join(workspace, 'peer-edit.txt'), 'PEER_BEFORE\n'); + const first = await editInChat(peer, 'peer-tools-first', workspace, 'peer-edit.txt', 'PEER_BEFORE', 'PEER_MIDDLE', 10); + const before = completedToolHistory(await readChat(peer)); + await restartAndInitialize('peer-tools-restored', workspace); + await fetchSessionWithChat(context.client, sessionUri); + assert.deepStrictEqual(completedToolHistory(await readChat(peer)), before); + const second = await editInChat(peer, 'peer-tools-second', workspace, 'peer-edit.txt', 'PEER_MIDDLE', 'PEER_AFTER', 1); + assertRequestContext([{ role: 'user', text: first }], ['DEFAULT_HISTORY_ONLY']); + assertRequestToolPair('PEER_MIDDLE'); + assert.deepStrictEqual({ + defaultMessages: (await readChat(chatUri)).turns.map(turn => turn.message.text), + peerMessages: (await readChat(peer)).turns.map(turn => turn.message.text), + }, { defaultMessages: [defaultPrompt], peerMessages: [first, second] }); + }); + + (peerChatPersistenceEnabled ? test : test.skip)('regression coverage: cold restored peers retain independent provider contexts', async function () { + this.timeout(240_000); + const { sessionUri, workspace } = await createPersistenceSession('peer-context-isolation'); + await driveTurnToCompletion(context.client, sessionUri, 'isolation-default', 'Reply exactly "DEFAULT_READY".', 1); + const first = await createHistoryPeer(sessionUri); + const second = await createHistoryPeer(sessionUri); + const firstPrompt = 'Remember PEER_RED_ONLY. Reply exactly "RED_READY".'; + const secondPrompt = 'Remember PEER_BLUE_ONLY. Reply exactly "BLUE_READY".'; + await driveChatTurnToCompletion(context.client, first, 'isolation-red', firstPrompt, 10); + await driveChatTurnToCompletion(context.client, second, 'isolation-blue', secondPrompt, 20); + await restartAndInitialize('peer-isolation-restored', workspace); + await fetchSessionWithChat(context.client, sessionUri); + assert.deepStrictEqual([ + (await readChat(first)).turns.map(turn => turn.message.text), + (await readChat(second)).turns.map(turn => turn.message.text), + ], [[firstPrompt], [secondPrompt]]); + await driveChatTurnToCompletion(context.client, second, 'isolation-blue-followup', 'Reply with the code word I gave this chat. Do not use tools.', 1); + assertRequestContext([{ role: 'user', text: secondPrompt }, { role: 'assistant', text: 'BLUE_READY' }], ['PEER_RED_ONLY']); + await driveChatTurnToCompletion(context.client, first, 'isolation-red-followup', 'Reply with the code word I gave this chat. Do not use tools.', 10); + assertRequestContext([{ role: 'user', text: firstPrompt }, { role: 'assistant', text: 'RED_READY' }], ['PEER_BLUE_ONLY']); + }); + + (peerChatPersistenceEnabled ? test : test.skip)('regression coverage: catalog refresh does not promote restored peer backings to sessions', async function () { + this.timeout(240_000); + const { sessionUri, workspace } = await createPersistenceSession('peer-backing-catalog'); + await driveTurnToCompletion(context.client, sessionUri, 'catalog-default', 'Reply exactly "CATALOG_READY".', 1); + const before = await context.client.call('listSessions', { channel: ROOT_STATE_URI }); + assert.ok(before.items.some(item => item.resource === sessionUri)); + const peer = await createHistoryPeer(sessionUri); + const prompt = 'Remember CATALOG_PEER_ONLY. Reply exactly "PEER_READY".'; + await driveChatTurnToCompletion(context.client, peer, 'catalog-peer', prompt, 10); + await restartAndInitialize('peer-catalog-restored', workspace); + await context.client.call('listSessions', { channel: ROOT_STATE_URI }); + await fetchSessionWithChat(context.client, sessionUri); + assert.deepStrictEqual((await readChat(peer)).turns.map(turn => turn.message.text), [prompt]); + for (let refresh = 0; refresh < 2; refresh++) { + const listed = await context.client.call('listSessions', { channel: ROOT_STATE_URI }); + assert.deepStrictEqual(listed.items.map(item => item.resource).sort(), before.items.map(item => item.resource).sort()); + const session = await context.client.call('subscribe', { channel: sessionUri }); + assert.strictEqual((session.snapshot!.state as SessionState).chats.filter(chat => chat.resource === peer).length, 1); + } + await driveChatTurnToCompletion(context.client, peer, 'catalog-peer-followup', 'Reply exactly "PEER_CONTINUED". Do not use tools.', 1); + assertRequestContext([{ role: 'user', text: prompt }, { role: 'assistant', text: 'PEER_READY' }]); + }); + + (peerChatPersistenceEnabled ? test : test.skip)('regression coverage: an unstarted peer can execute its first tool turn after restart', async function () { + this.timeout(240_000); + const { sessionUri, workspace } = await createPersistenceSession('unstarted-peer'); + await driveTurnToCompletion(context.client, sessionUri, 'unstarted-default', 'Remember DEFAULT_NOT_IN_PEER. Reply exactly "READY".', 1); + const completedPeer = await createHistoryPeer(sessionUri); + await driveChatTurnToCompletion(context.client, completedPeer, 'unstarted-sibling', 'Remember SIBLING_NOT_IN_PEER. Reply exactly "READY".', 10); + const unstartedPeer = await createHistoryPeer(sessionUri); + assert.deepStrictEqual((await readChat(unstartedPeer)).turns, []); + fs.writeFileSync(join(workspace, 'first-peer.txt'), 'FIRST_BEFORE\n'); + await restartAndInitialize('unstarted-peer-restored', workspace); + const session = await context.client.call('subscribe', { channel: sessionUri }); + assert.ok((session.snapshot!.state as SessionState).chats.some(chat => chat.resource === unstartedPeer)); + assert.deepStrictEqual((await readChat(unstartedPeer)).turns, []); + const prompt = await editInChat(unstartedPeer, 'unstarted-first-edit', workspace, 'first-peer.txt', 'FIRST_BEFORE', 'FIRST_AFTER', 1); + assertRequestContext([{ role: 'user', text: prompt }], ['DEFAULT_NOT_IN_PEER', 'SIBLING_NOT_IN_PEER']); + assert.strictEqual((await readChat(completedPeer)).turns.length, 1); + }); + + (forkHistoryEnabled ? test : test.skip)('regression coverage: a first-turn fork excludes later source context', async function () { + this.timeout(240_000); + const { sessionUri, chatUri } = await createPersistenceSession('first-turn-fork'); + const firstPrompt = 'Remember FIRST_FORK_INCLUDED. Reply exactly "FIRST_READY".'; + const laterPrompt = 'Remember LATER_FORK_EXCLUDED. Reply exactly "LATER_READY".'; + await driveChatTurnToCompletion(context.client, chatUri, 'fork-first-source', firstPrompt, 1); + await driveChatTurnToCompletion(context.client, chatUri, 'fork-later-source', laterPrompt, 10); + const fork = await createHistoryPeer(sessionUri, { chat: chatUri, turnId: 'fork-first-source' }); + assert.deepStrictEqual((await readChat(fork)).turns.map(turn => turn.message.text), [firstPrompt]); + await driveChatTurnToCompletion(context.client, fork, 'fork-first-followup', 'Reply with the code word from the source conversation. Do not use tools.', 20); + assertRequestContext([{ role: 'user', text: firstPrompt }, { role: 'assistant', text: 'FIRST_READY' }], ['LATER_FORK_EXCLUDED', 'LATER_READY']); + assert.deepStrictEqual((await readChat(chatUri)).turns.map(turn => turn.message.text), [firstPrompt, laterPrompt]); + }); + + (forkHistoryEnabled ? test : test.skip)('regression coverage: a middle-turn fork includes completed tools but excludes the next turn', async function () { + this.timeout(240_000); + const { sessionUri, chatUri, workspace } = await createPersistenceSession('middle-tool-fork'); + const seed = 'Remember MIDDLE_FORK_SEED. Reply exactly "SEED_READY".'; + await driveChatTurnToCompletion(context.client, chatUri, 'middle-seed', seed, 1); + fs.writeFileSync(join(workspace, 'middle.txt'), 'MIDDLE_BEFORE\n'); + const editPrompt = await editInChat(chatUri, 'middle-selected', workspace, 'middle.txt', 'MIDDLE_BEFORE', 'MIDDLE_AFTER', 10); + const selected = completedToolHistory(await readChat(chatUri)); + const excluded = 'Remember AFTER_BOUNDARY_EXCLUDED. Reply exactly "EXCLUDED_READY".'; + await driveChatTurnToCompletion(context.client, chatUri, 'middle-excluded', excluded, 20); + const fork = await createHistoryPeer(sessionUri, { chat: chatUri, turnId: 'middle-selected' }); + assert.deepStrictEqual(completedToolHistory(await readChat(fork)), selected); + await driveChatTurnToCompletion(context.client, fork, 'middle-followup', 'Reply exactly "FORK_HISTORY_READY". Do not use tools.', 30); + assertRequestContext([{ role: 'user', text: seed }, { role: 'assistant', text: 'SEED_READY' }, { role: 'user', text: editPrompt }, { role: 'assistant', text: 'EDIT_DONE' }], ['AFTER_BOUNDARY_EXCLUDED', 'EXCLUDED_READY']); + assertRequestToolPair('MIDDLE_AFTER'); + assert.strictEqual(fs.readFileSync(join(workspace, 'middle.txt'), 'utf8'), 'MIDDLE_AFTER\n'); + }); + + (restoredForkHistoryEnabled ? test : test.skip)('regression coverage: a restored peer can be forked without changing its source history', async function () { + this.timeout(240_000); + const { sessionUri, chatUri, workspace } = await createPersistenceSession('restored-peer-fork'); + const defaultPrompt = 'Remember DEFAULT_FORK_EXCLUDED. Reply exactly "DEFAULT_READY".'; + await driveChatTurnToCompletion(context.client, chatUri, 'restored-fork-default', defaultPrompt, 1); + const source = await createHistoryPeer(sessionUri); + const selectedPrompt = 'Remember PEER_FORK_INCLUDED. Reply exactly "PEER_READY".'; + const laterPrompt = 'Remember PEER_LATER_EXCLUDED. Reply exactly "PEER_LATER_READY".'; + await driveChatTurnToCompletion(context.client, source, 'restored-fork-selected', selectedPrompt, 10); + await driveChatTurnToCompletion(context.client, source, 'restored-fork-later', laterPrompt, 20); + await restartAndInitialize('restored-peer-fork-client', workspace); + await fetchSessionWithChat(context.client, sessionUri); + const sourceBefore = await readChat(source); + const selectedTurn = sourceBefore.turns.find(turn => turn.message.text === selectedPrompt); + assert.ok(selectedTurn); + const fork = await createHistoryPeer(sessionUri, { chat: source, turnId: selectedTurn.id }); + assert.deepStrictEqual((await readChat(fork)).turns.map(turn => turn.message.text), [selectedPrompt]); + assert.deepStrictEqual(completedToolHistory(await readChat(source)), completedToolHistory(sourceBefore)); + await driveChatTurnToCompletion(context.client, fork, 'restored-fork-followup', 'Reply exactly "FORK_CONTINUED". Do not use tools.', 1); + assertRequestContext([{ role: 'user', text: selectedPrompt }, { role: 'assistant', text: 'PEER_READY' }], ['DEFAULT_FORK_EXCLUDED', 'PEER_LATER_EXCLUDED']); + fs.writeFileSync(join(workspace, 'source-continues.txt'), 'SOURCE_BEFORE\n'); + // Codex 0.157.0 intercepts this heredoc for Unix, PowerShell, and cmd before shell execution. + const continuationPrompt = config.provider === 'codex' + ? 'Call exec_command exactly once with this exact command, without modifying or wrapping it:\n' + + '```sh\napply_patch <<\'PATCH\'\n*** Begin Patch\n*** Update File: source-continues.txt\n@@\n-SOURCE_BEFORE\n+SOURCE_AFTER\n*** End Patch\nPATCH\n```\n' + + 'Do not inspect or search for files, and do not run any other command. After the patch succeeds, reply exactly "EDIT_DONE".' + : undefined; + const continuation = await editInChat(source, 'restored-source-continues', workspace, 'source-continues.txt', 'SOURCE_BEFORE', 'SOURCE_AFTER', 10, continuationPrompt); + assertRequestContext([{ role: 'user', text: selectedPrompt }, { role: 'user', text: laterPrompt }], ['FORK_CONTINUED', 'DEFAULT_FORK_EXCLUDED']); + assert.deepStrictEqual((await readChat(source)).turns.map(turn => turn.message.text), [selectedPrompt, laterPrompt, continuation]); + }); + + (restoredForkHistoryEnabled ? test : test.skip)('regression coverage: a fork resumes its own branch after the source advances', async function () { + this.timeout(240_000); + const { sessionUri, chatUri, workspace } = await createPersistenceSession('fork-branch-resume'); + const sourcePrompt = 'Remember SHARED_FORK_PREFIX. Reply exactly "PREFIX_READY".'; + await driveChatTurnToCompletion(context.client, chatUri, 'branch-source', sourcePrompt, 1); + const fork = await createHistoryPeer(sessionUri, { chat: chatUri, turnId: 'branch-source' }); + const branchPrompt = 'Remember FORK_BRANCH_ONLY. Reply exactly "BRANCH_READY".'; + await driveChatTurnToCompletion(context.client, fork, 'branch-fork', branchPrompt, 10); + const sourceAdvance = 'Remember SOURCE_ADVANCE_ONLY. Reply exactly "SOURCE_ADVANCED".'; + await driveChatTurnToCompletion(context.client, chatUri, 'branch-source-advance', sourceAdvance, 20); + await restartAndInitialize('fork-branch-restored', workspace); + await fetchSessionWithChat(context.client, sessionUri); + assert.deepStrictEqual([ + (await readChat(fork)).turns.map(turn => turn.message.text), + (await readChat(chatUri)).turns.map(turn => turn.message.text), + ], [[sourcePrompt, branchPrompt], [sourcePrompt, sourceAdvance]]); + await driveChatTurnToCompletion(context.client, fork, 'branch-resumed-fork', 'Reply exactly "BRANCH_RESUMED". Do not use tools.', 1); + assertRequestContext([{ role: 'user', text: sourcePrompt }, { role: 'assistant', text: 'PREFIX_READY' }, { role: 'user', text: branchPrompt }, { role: 'assistant', text: 'BRANCH_READY' }], ['SOURCE_ADVANCE_ONLY', 'SOURCE_ADVANCED']); + await driveChatTurnToCompletion(context.client, chatUri, 'branch-resumed-source', 'Reply exactly "SOURCE_RESUMED". Do not use tools.', 10); + assertRequestContext([{ role: 'user', text: sourcePrompt }, { role: 'user', text: sourceAdvance }], ['FORK_BRANCH_ONLY', 'BRANCH_READY', 'BRANCH_RESUMED']); + }); } diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/terminalResilienceSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/terminalResilienceSuite.ts new file mode 100644 index 00000000000000..bb6e9bf93fa2b2 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/suites/terminalResilienceSuite.ts @@ -0,0 +1,164 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { existsSync, mkdtempSync, readFileSync, realpathSync, statSync, writeFileSync } from 'fs'; +import { createRequire } from 'module'; +import { tmpdir } from 'os'; +import { retry } from '../../../../../../base/common/async.js'; +import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { join } from '../../../../../../base/common/path.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { generateUuid } from '../../../../../../base/common/uuid.js'; +import type { SubscribeResult } from '../../../../common/state/protocol/commands.js'; +import { PROTOCOL_VERSION } from '../../../../common/state/protocol/version/registry.js'; +import { ActionType } from '../../../../common/state/sessionActions.js'; +import { ROOT_STATE_URI, type TerminalState } from '../../../../common/state/sessionState.js'; +import { TerminalClaimKind } from '../../../../common/state/protocol/channels-terminal/state.js'; +import { terminalText } from '../harness/agentHostE2ETestHarness.js'; +import { conformanceTest, type IAgentHostE2ETestContext } from './e2eTestContext.js'; + +const { Terminal }: Pick = createRequire(import.meta.url)('@xterm/headless'); + +export function defineTerminalResilienceTests(context: IAgentHostE2ETestContext): void { + let sequence = 400_000; + let ordinal = 0; + + async function state(terminal: string): Promise { + const result = await context.client.call('subscribe', { channel: terminal }); + return result.snapshot!.state as TerminalState; + } + + function input(terminal: string, script: string): void { + context.client.dispatch({ + channel: terminal, clientSeq: sequence++, + action: { type: ActionType.TerminalInput, data: `node -e "${script}"\r` }, + }); + } + + async function output(terminal: string, marker: string): Promise { + return retry(async () => { + const text = terminalText(await state(terminal)); + assert.ok(text.includes(marker), `Terminal has not produced ${marker}; output: ${text}`); + return text; + }, 100, 300); + } + + async function withTerminals(count: number, run: (terminals: readonly { uri: string; workspace: string }[]) => Promise): Promise { + const clientId = `terminal-resilience-${ordinal++}`; + await context.client.call('initialize', { channel: ROOT_STATE_URI, protocolVersions: [PROTOCOL_VERSION], clientId }); + const terminals: { uri: string; workspace: string }[] = []; + try { + for (let i = 0; i < count; i++) { + const workspace = realpathSync(mkdtempSync(join(tmpdir(), 'ahp-terminal-resilience-'))); + context.tempDirs.push(workspace); + const uri = URI.from({ scheme: 'agenthost-terminal', authority: 'e2e', path: `/${generateUuid()}` }).toString(); + await context.client.call('createTerminal', { + channel: uri, + claim: { kind: TerminalClaimKind.Client, clientId }, + cwd: URI.file(workspace).toString(), + cols: 90, rows: 30, + }); + terminals.push({ uri, workspace }); + await state(uri); + } + await run(terminals); + } finally { + for (const terminal of terminals) { + await context.client.call('disposeTerminal', { channel: terminal.uri }); + } + } + } + + conformanceTest(context, 'regression coverage: a client-owned terminal launches in its requested working directory', async function () { + await withTerminals(1, async ([terminal]) => { + input(terminal.uri, `require('fs').writeFileSync('cwd.txt',process.cwd());console.log('CWD_'+'READY')`); + await output(terminal.uri, 'CWD_READY'); + const actual = statSync(readFileSync(join(terminal.workspace, 'cwd.txt'), 'utf8')); + const requested = statSync(terminal.workspace); + assert.deepStrictEqual({ dev: actual.dev, ino: actual.ino }, { dev: requested.dev, ino: requested.ino }); + }); + }); + + conformanceTest(context, 'regression coverage: terminal input preserves UTF-8 bytes for a running child process', async function () { + await withTerminals(1, async ([terminal]) => { + // Read PTY input in the child, independently of the shell editor's inherited locale. + input(terminal.uri, `const fs=require('fs');let text='';process.stdin.setEncoding('utf8');process.stdin.on('data',chunk=>{text+=chunk;if(text.includes('\\n')===false)return;fs.writeFileSync('unicode.txt',text.trim());process.stdin.pause();console.log('UNICODE_'+'DONE')});console.log('INPUT_'+'READY')`); + await output(terminal.uri, 'INPUT_READY'); + context.client.dispatch({ + channel: terminal.uri, clientSeq: sequence++, + action: { type: ActionType.TerminalInput, data: 'caf\u00e9\r' }, + }); + await output(terminal.uri, 'UNICODE_DONE'); + assert.strictEqual(readFileSync(join(terminal.workspace, 'unicode.txt'), 'utf8'), 'caf\u00e9'); + }); + }); + + conformanceTest(context, 'regression coverage: terminal resizing reaches the real child process', async function () { + await withTerminals(1, async ([terminal]) => { + context.client.dispatch({ + channel: terminal.uri, clientSeq: sequence++, + action: { type: ActionType.TerminalResized, cols: 73, rows: 19 }, + }); + await retry(async () => { + const current = await state(terminal.uri); + assert.deepStrictEqual({ cols: current.cols, rows: current.rows }, { cols: 73, rows: 19 }); + }, 50, 100); + input(terminal.uri, `console.log('SIZE_'+process.stdout.columns+'x'+process.stdout.rows)`); + await output(terminal.uri, 'SIZE_73x19'); + }); + }); + + conformanceTest(context, 'regression coverage: terminal scrollback preserves a large fragmented process output', async function () { + await withTerminals(1, async ([terminal]) => { + input(terminal.uri, `console.log('x'.repeat(40000)+'LONG_'+'DONE')`); + await output(terminal.uri, 'LONG_DONE'); + const current = await state(terminal.uri); + const disposables = new DisposableStore(); + try { + const screen = disposables.add(new Terminal({ cols: current.cols, rows: current.rows, scrollback: 5000, allowProposedApi: true })); + await new Promise(resolve => screen.write(current.content.map(part => part.type === 'command' ? part.output : part.value).join(''), resolve)); + let runLength = 0; + for (let line = 0; line < screen.buffer.active.length; line++) { + const text = screen.buffer.active.getLine(line)?.translateToString(true) ?? ''; + runLength += [...text.matchAll(/x{20,}/g)].reduce((length, match) => length + match[0].length, 0); + } + assert.strictEqual(runLength, 40000); + } finally { + disposables.dispose(); + } + }); + }); + + conformanceTest(context, 'regression coverage: terminal input and output stay isolated between two claimed processes', async function () { + await withTerminals(2, async ([first, second]) => { + input(first.uri, `require('fs').writeFileSync('owner.txt','first');console.log('FIRST_'+'DONE')`); + await output(first.uri, 'FIRST_DONE'); + assert.strictEqual(existsSync(join(second.workspace, 'owner.txt')), false); + assert.ok(!terminalText(await state(second.uri)).includes('FIRST_DONE')); + input(second.uri, `require('fs').writeFileSync('owner.txt','second');console.log('SECOND_'+'DONE')`); + await output(second.uri, 'SECOND_DONE'); + assert.deepStrictEqual({ + first: readFileSync(join(first.workspace, 'owner.txt'), 'utf8'), + second: readFileSync(join(second.workspace, 'owner.txt'), 'utf8'), + firstSawSecond: terminalText(await state(first.uri)).includes('SECOND_DONE'), + }, { first: 'first', second: 'second', firstSawSecond: false }); + }); + }); + + conformanceTest(context, 'regression coverage: terminal process output remains readable after an unsubscribed interval', async function () { + await withTerminals(1, async ([terminal]) => { + writeFileSync(join(terminal.workspace, 'trigger.txt'), 'before'); + input(terminal.uri, `const fs=require('fs');fs.watchFile('trigger.txt',{interval:50},()=>{fs.unwatchFile('trigger.txt');fs.writeFileSync('witness.txt','done');console.log('LATER_'+'OUTPUT')});console.log('WAIT_'+'READY')`); + await output(terminal.uri, 'WAIT_READY'); + context.client.notify('unsubscribe', { channel: terminal.uri }); + await context.client.call('listSessions', { channel: ROOT_STATE_URI }); + writeFileSync(join(terminal.workspace, 'trigger.txt'), 'after'); + await retry(async () => assert.ok(existsSync(join(terminal.workspace, 'witness.txt'))), 50, 100); + const restored = await output(terminal.uri, 'LATER_OUTPUT'); + assert.ok(restored.includes('WAIT_READY')); + }); + }); +} diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/workspaceConversionSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/workspaceConversionSuite.ts index 73b91723591c45..341de911a0ae39 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/workspaceConversionSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/workspaceConversionSuite.ts @@ -116,14 +116,14 @@ export function defineWorkspaceConversionTests(context: IAgentHostE2ETestContext setWorkspaceCalled, workspaceless: readSessionWorkspaceless(state._meta), workingDirectories: state.workingDirectories, - canAttachWorkspace: state.serverTools?.some(tool => tool.name === SessionServerToolName.SetWorkspace), + canChangeWorkspace: state.serverTools?.some(tool => tool.name === SessionServerToolName.SetWorkspace), mentionsConversion: getMarkdownResponseText(context.client).toLowerCase().includes('converted'), }, { sawWorkspaceQuestion: true, setWorkspaceCalled: true, workspaceless: false, workingDirectories: [workspaceUri], - canAttachWorkspace: false, + canChangeWorkspace: true, mentionsConversion: true, }); context.client.clearReceived(); diff --git a/src/vs/platform/agentHost/test/node/fileEditTracker.test.ts b/src/vs/platform/agentHost/test/node/fileEditTracker.test.ts index f1c955509f223c..febd0c42604d41 100644 --- a/src/vs/platform/agentHost/test/node/fileEditTracker.test.ts +++ b/src/vs/platform/agentHost/test/node/fileEditTracker.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { DeferredPromise } from '../../../../base/common/async.js'; import { VSBuffer } from '../../../../base/common/buffer.js'; -import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { FileService } from '../../../files/common/fileService.js'; @@ -22,7 +22,7 @@ import { parseSessionDbUri } from '../../common/sessionDbUri.js'; import { buildChatUri, ToolResultContentType } from '../../common/state/sessionState.js'; import { TestDiffComputeService } from '../common/sessionTestHelpers.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; -import { IEditSurvivalReporterFactory, NullEditSurvivalReporterFactory } from '../../node/shared/editSurvivalReporter.js'; +import { IEditSurvivalReporterFactory, NullEditSurvivalReporterFactory, type IEditSurvivalReporterLaunchParams } from '../../node/shared/editSurvivalReporter.js'; import { FileEditTracker } from '../../node/shared/fileEditTracker.js'; import { IEditArcReporterLaunchParams, IEditArcReporterService, NullEditArcReporterService } from '../../node/shared/editArcReporter.js'; @@ -34,6 +34,30 @@ suite('FileEditTracker', () => { let tracker: FileEditTracker; let diffComputeService: TestDiffComputeService; + for (const provider of ['copilotcli', 'codex', 'claude']) { + test(`standard sessions forward explicit ${provider} edit attribution and telemetry`, async () => { + const providers: (string | undefined)[] = []; + const services = new ServiceCollection( + [ILogService, new NullLogService()], + [IFileService, fileService], + [IDiffComputeService, diffComputeService], + [IAgentEditAttributionService, new class extends NullAgentEditAttributionService { + override async recordEdit(edit: IAgentEditAttribution) { providers.push(edit.provider); return undefined; } + }()], + [IEditSurvivalReporterFactory, { _serviceBrand: undefined, launch: (params: IEditSurvivalReporterLaunchParams) => { providers.push(params.provider); return Disposable.None; } }], + [IEditArcReporterService, { _serviceBrand: undefined, reportEdit: async (params: IEditArcReporterLaunchParams) => { providers.push(params.provider); } }], + ); + const instantiationService = disposables.add(new InstantiationService(services)); + const localTracker = instantiationService.createInstance(FileEditTracker, 'ahp-session:/standard', db, provider); + await fileService.writeFile(URI.file('/workspace/standard.txt'), VSBuffer.fromString('before')); + await localTracker.trackEditStart('/workspace/standard.txt'); + await fileService.writeFile(URI.file('/workspace/standard.txt'), VSBuffer.fromString('after')); + await localTracker.completeEdit('/workspace/standard.txt'); + await localTracker.takeCompletedEdit('turn-1', 'tc-standard', '/workspace/standard.txt', 'edit', undefined, 'model'); + assert.deepStrictEqual(providers, [provider, provider, provider]); + }); + } + setup(async () => { fileService = disposables.add(new FileService(new NullLogService())); const sourceFs = disposables.add(new InMemoryFileSystemProvider()); @@ -51,7 +75,7 @@ suite('FileEditTracker', () => { services.set(IEditSurvivalReporterFactory, new NullEditSurvivalReporterFactory()); services.set(IEditArcReporterService, new NullEditArcReporterService()); const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); - tracker = instantiationService.createInstance(FileEditTracker, 'copilot:/test-session', db); + tracker = instantiationService.createInstance(FileEditTracker, 'copilot:/test-session', db, undefined); }); teardown(async () => { @@ -146,7 +170,7 @@ suite('FileEditTracker', () => { reportEdit: async () => { arcReportCount++; }, }); const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); - const localTracker = instantiationService.createInstance(FileEditTracker, 'copilot:/test-session', db); + const localTracker = instantiationService.createInstance(FileEditTracker, 'copilot:/test-session', db, undefined); await fileService.writeFile(URI.file('/workspace/marker.txt'), VSBuffer.fromString('before')); await localTracker.trackEditStart('/workspace/marker.txt'); @@ -193,7 +217,7 @@ suite('FileEditTracker', () => { services.set(IEditSurvivalReporterFactory, new NullEditSurvivalReporterFactory()); services.set(IEditArcReporterService, new NullEditArcReporterService()); const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); - const localTracker = instantiationService.createInstance(FileEditTracker, 'copilot:/test-session', db); + const localTracker = instantiationService.createInstance(FileEditTracker, 'copilot:/test-session', db, undefined); await fileService.writeFile(URI.file('/workspace/fallback.txt'), VSBuffer.fromString('before')); await localTracker.trackEditStart('/workspace/fallback.txt'); @@ -228,7 +252,7 @@ suite('FileEditTracker', () => { }, }); const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); - const localTracker = instantiationService.createInstance(FileEditTracker, 'copilot:/test-session', db); + const localTracker = instantiationService.createInstance(FileEditTracker, 'copilot:/test-session', db, undefined); await fileService.writeFile(URI.file('/workspace/non-blocking.txt'), VSBuffer.fromString('before')); await localTracker.trackEditStart('/workspace/non-blocking.txt', 'plan'); @@ -282,7 +306,7 @@ suite('FileEditTracker', () => { services.set(IEditSurvivalReporterFactory, new NullEditSurvivalReporterFactory()); services.set(IEditArcReporterService, new NullEditArcReporterService()); const inst: IInstantiationService = disposables.add(new InstantiationService(services)); - const localTracker = inst.createInstance(FileEditTracker, 'copilot:/test-session', db); + const localTracker = inst.createInstance(FileEditTracker, 'copilot:/test-session', db, undefined); await localTracker.trackEditStart('/workspace/brand-new.txt'); await fileService.writeFile(URI.file('/workspace/brand-new.txt'), VSBuffer.fromString('fresh')); diff --git a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts index bbea7e94b656ee..fef423919233d4 100644 --- a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts +++ b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts @@ -1086,75 +1086,79 @@ suite('mapSessionEvents — history replay', () => { }); }); - test('restores MCP app data for completed tool calls', async () => { - const events: ISessionEvent[] = [ - { type: 'user.message', data: { interactionId: 'm1', content: 'call an MCP app tool' } }, - { - type: 'assistant.message', - data: { - messageId: 'm2', - content: '', - toolRequests: [{ + for (const scheme of ['copilot', 'ahp-session']) { + test(`restores MCP app data for completed tool calls on ${scheme} resources`, async () => { + const events: ISessionEvent[] = [ + { type: 'user.message', data: { interactionId: 'm1', content: 'call an MCP app tool' } }, + { + type: 'assistant.message', + data: { + messageId: 'm2', + content: '', + toolRequests: [{ + toolCallId: 'tc-1', + name: 'GitHub-get_me', + arguments: {}, + type: 'function', + mcpServerName: 'GitHub', + mcpToolName: 'get_me', + }], + }, + }, + { + type: 'tool.execution_start', + data: { toolCallId: 'tc-1', - name: 'GitHub-get_me', + toolName: 'GitHub-get_me', arguments: {}, - type: 'function', mcpServerName: 'GitHub', mcpToolName: 'get_me', - }], - }, - }, - { - type: 'tool.execution_start', - data: { - toolCallId: 'tc-1', - toolName: 'GitHub-get_me', - arguments: {}, - mcpServerName: 'GitHub', - mcpToolName: 'get_me', - toolDescription: { - _meta: { - ui: { - resourceUri: 'ui://github-mcp-server/get-me', + toolDescription: { + _meta: { + ui: { + resourceUri: 'ui://github-mcp-server/get-me', + }, }, }, }, }, - }, - { - type: 'tool.execution_complete', - data: { - toolCallId: 'tc-1', - success: true, - result: { content: '{"login":"octocat"}' }, + { + type: 'tool.execution_complete', + data: { + toolCallId: 'tc-1', + success: true, + result: { content: '{"login":"octocat"}' }, + }, }, - }, - ]; + ]; - const chatUri = URI.parse(buildChatUri(session, 'restored-chat')); - const sdkConversationUri = URI.parse('copilot-sdk:/conversation-123'); - const { turns } = await mapSessionEventsWithRouting(sdkConversationUri, undefined, toSessionEvents(events), chatUri); + const owner = AgentSession.uri(scheme, 'test-session'); + const providerId = scheme === 'ahp-session' ? 'copilotcli' : scheme; + const chatUri = URI.parse(buildChatUri(owner, 'restored-chat')); + const sdkConversationUri = URI.parse('copilot-sdk:/conversation-123'); + const { turns } = await mapSessionEventsWithRouting(sdkConversationUri, undefined, toSessionEvents(events), chatUri); - const part = turns[0].responseParts[0] as ToolCallResponsePart; - assert.strictEqual(part.kind, ResponsePartKind.ToolCall); - assert.deepStrictEqual({ - contributor: part.toolCall.contributor, - meta: readToolCallMeta(part.toolCall), - }, { - contributor: { - kind: ToolCallContributorKind.MCP, - customizationId: 'mcp-top-level:copilot:test-session:GitHub', - }, - meta: { - mcpServerName: 'GitHub', - mcpToolName: 'get_me', - ui: { - resourceUri: 'ui://github-mcp-server/get-me', - channel: `mcp://copilot/${encodeURIComponent(chatUri.toString())}/GitHub`, + const part = turns[0].responseParts[0] as ToolCallResponsePart; + assert.strictEqual(part.kind, ResponsePartKind.ToolCall); + assert.deepStrictEqual({ + contributor: part.toolCall.contributor, + meta: readToolCallMeta(part.toolCall), + }, { + contributor: { + kind: ToolCallContributorKind.MCP, + customizationId: `mcp-top-level:${providerId}:test-session:GitHub`, }, - }, + meta: { + mcpServerName: 'GitHub', + mcpToolName: 'get_me', + ui: { + resourceUri: 'ui://github-mcp-server/get-me', + channel: `mcp://${providerId}/${encodeURIComponent(chatUri.toString())}/GitHub`, + }, + }, + }); }); - }); + } test('derives shell tool intention from the description argument on replay', async () => { const events: ISessionEvent[] = [ @@ -1171,6 +1175,27 @@ suite('mapSessionEvents — history replay', () => { assert.strictEqual(part.toolCall.intention, 'List files in the repo root'); }); + test('restores image function tools before the final answer', async () => { + const { turns } = await mapSessionEvents(session, undefined, toSessionEvents([ + { type: 'user.message', data: { content: 'Draw a puppy.' } }, + { type: 'assistant.message', data: { messageId: 'image-request', content: 'I will create an image.', toolRequests: [{ toolCallId: 'image-1', name: 'image_generation' }] } }, + { type: 'tool.execution_start', data: { toolCallId: 'image-1', toolName: 'image_generation', arguments: { prompt: 'Draw a puppy' } } }, + { type: 'tool.execution_complete', data: { toolCallId: 'image-1', success: true, result: { contents: [{ type: 'image', data: 'aW1hZ2U=', mimeType: 'image/png' }] } } }, + { type: 'assistant.message', data: { messageId: 'image-result', content: 'Image generation completed.' } }, + ])); + + assert.deepStrictEqual(turns[0].responseParts.map(part => { + if (part.kind === ResponsePartKind.ToolCall && part.toolCall.status === ToolCallStatus.Completed) { + return { kind: part.kind, toolCallId: part.toolCall.toolCallId, success: part.toolCall.success }; + } + return part.kind === ResponsePartKind.Markdown ? { kind: part.kind, content: part.content } : { kind: part.kind }; + }), [ + { kind: ResponsePartKind.Markdown, content: 'I will create an image.' }, + { kind: ResponsePartKind.ToolCall, toolCallId: 'image-1', success: true }, + { kind: ResponsePartKind.Markdown, content: 'Image generation completed.' }, + ]); + }); + test('maps SDK image content to an embedded resource on replayed tool completion', async () => { const events: ISessionEvent[] = [ { type: 'user.message', data: { interactionId: 'm1', content: 'view the image' } }, @@ -1200,6 +1225,42 @@ suite('mapSessionEvents — history replay', () => { ]); }); + test('does not advertise opaque SDK resource links as readable host content on replay', async () => { + const uri = 'generated-images:/session/generated-image.png?version=1'; + const imageGeneration = { requestedModel: { id: 'image-preview', name: 'Image Preview' } }; + const events: ISessionEvent[] = [ + { type: 'user.message', data: { interactionId: 'm1', content: 'Draw a puppy' } }, + { type: 'tool.execution_start', data: { toolCallId: 'tc-image', toolName: 'image_generation' } }, + { + type: 'tool.execution_complete', + data: { + toolCallId: 'tc-image', + success: true, + result: { + content: 'Generated an image.', + structuredContent: { imageGeneration }, + contents: [{ type: 'resource_link', uri, name: 'generated-image.png', mimeType: 'image/png', size: 128 }], + }, + }, + }, + ]; + + const { turns } = await mapSessionEvents(session, undefined, toSessionEvents(events)); + const part = turns[0].responseParts[0]; + assert.ok(part.kind === ResponsePartKind.ToolCall && part.toolCall.status === ToolCallStatus.Completed); + assert.deepStrictEqual({ + content: part.toolCall.content, + title: part.toolCall.pastTenseMessage, + meta: part.toolCall._meta, + }, { + content: [ + { type: ToolResultContentType.Text, text: 'Generated an image.' }, + ], + title: 'Generated image with Image Preview', + meta: { 'vscode.imageGeneration': imageGeneration }, + }); + }); + test('maps SDK shell_exit full output to terminal completion on replay', async () => { const events: ISessionEvent[] = [ { type: 'user.message', data: { interactionId: 'm1', content: 'hi' } }, @@ -2082,6 +2143,16 @@ suite('appendSdkToolResultContent', () => { } } + test('does not convert unsupported SDK links into host-readable resources', () => { + const content: ToolResultContent[] = []; + appendSdkToolResultContent(content, [ + { type: 'resource_link', uri: 'generated-images:/session/result', name: 'result' }, + { type: 'resource_link', uri: 'https://example.com/image.png', name: 'image', mimeType: 'image/png' }, + { type: 'resource_link', uri: 'mcp:/document', name: 'document', mimeType: 'text/plain' }, + ]); + assert.deepStrictEqual(content, []); + }); + test('folds shell_exit into an existing terminal block instead of adding a second one', () => { const content: ToolResultContent[] = [ { type: ToolResultContentType.Terminal, resource: 'agenthost-terminal://shell/abc', title: 'Bash' }, diff --git a/src/vs/platform/agentHost/test/node/missionControlAuthentication.test.ts b/src/vs/platform/agentHost/test/node/missionControlAuthentication.test.ts new file mode 100644 index 00000000000000..863a76aa55d0c5 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/missionControlAuthentication.test.ts @@ -0,0 +1,197 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { createHash, randomBytes } from 'crypto'; +import sodium from 'libsodium-wrappers'; +import { DeferredPromise } from '../../../../base/common/async.js'; +import { JsonRpcErrorCodes } from '../../common/state/protocol/common/errors.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { MissionControlAuthentication, MissionControlSealing, resolveMissionControlOwner, sealMissionControlCredential } from '../../node/missionControl/missionControlAuthentication.js'; + +suite('Mission Control sealed authentication', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + const resource = 'https://api.github.com'; + setup(() => MissionControlSealing.ready()); + + function seal(sealing: MissionControlSealing, value: string, connection?: object, use = 'auth-token', target = resource): string { + const key = sealing.advertisedKeys.find(key => key.use === use)!; + const box = sodium.crypto_box_seal(JSON.stringify({ cty: 'text', ctx: { purpose: use, resource: target, ...(connection ? { connection } : {}) }, value }), Buffer.from(key.public_key, 'base64')); + return `copilot-sealed.v1.${key.key_id}.${Buffer.from(box).toString('base64url')}`; + } + + test('trusted local sealing produces purpose-specific, resource-bound portable tokens', async () => { + const sealing = store.add(new MissionControlSealing()); + const key = sealing.advertisedKeys.find(key => key.use === 'mcp-auth-token'); + assert.ok(key); + const challenge = randomBytes(16).toString('hex'); + const target = 'https://mcp.example.test'; + const envelope = await sealMissionControlCredential({ token: 'test-mcp-token', resource: target, key: { ...key, use: 'mcp-auth-token' }, challenge }); + const opened = sealing.open(envelope, 'mcp-auth-token', target); + assert.strictEqual(opened.token, 'test-mcp-token'); + assert.strictEqual(opened.connection?.challenge, challenge); + assert.match(String(opened.connection?.nonce), /^[0-9a-f]{32}$/); + assert.throws(() => sealing.open(envelope, 'auth-token', target), /Unknown sealing key/); + assert.throws(() => sealing.open(envelope, 'mcp-auth-token', 'https://different.test'), /context/); + await assert.rejects(sealMissionControlCredential({ token: 'test', resource: target, key: { ...key, key_id: 'mismatched', use: 'mcp-auth-token' } }), /recipient key/); + }); + + test('opens the independently produced portable sealed-box vector', () => { + const sealing = store.add(new MissionControlSealing([{ use: 'auth-token', privateKey: Buffer.from('aSFRt0ENOIh44Ovgwe34W2STEqRZg1CKS7TJg2yanWs=', 'base64') }])); + const opened = sealing.open('copilot-sealed.v1.-a-KYd0rURg.AOfR8IBb-j-x6ALl6DBMtN-C-0VelwcnnRwsdDcRqy5_EoYzSifMl63NyZKMBSkIr8-tFa_v6YMXG7coG4ZAwz_Tk8IvlAiIV72hsKvT-QX6K6CflsTCyU-lyq5xIO8o8LoqSzX3EKRO2kL9B3iWAuEypz2p_gXubPV-bqbVAkYO7cMmHhKFLMrjb7aPnNSGit_Wy8nI6kPxYB5bMVDji2jcIFQGf1D_ojeLoesehHto7jT0', 'auth-token', resource); + assert.deepStrictEqual({ + key: sealing.advertisedKeys[0].key_id, + tokenHash: createHash('sha256').update(opened.token).digest('hex'), + connection: opened.connection, + }, { + key: '-a-KYd0rURg', + tokenHash: '50ca8da854b9006de85269d122e0d0f77a9cc86f9b20a0691b4b7b7ce2a557b2', + connection: undefined, + }); + }); + + test('requires sealed values, separates purposes, and checks resources', () => { + const sealing = store.add(new MissionControlSealing()); + assert.throws(() => sealing.open('plaintext-credential', 'auth-token', resource), /requires a sealed/); + assert.throws(() => sealing.open(seal(sealing, 'test', undefined, 'mcp-auth-token'), 'auth-token', resource), /Unknown sealing key/); + assert.throws(() => sealing.open(seal(sealing, 'test', undefined, 'auth-token', 'https://other.test'), 'auth-token', resource), /context/); + const box = seal(sealing, 'test'); + const parts = box.split('.'); + const corrupted = Buffer.from(parts[3], 'base64url'); + corrupted[corrupted.length - 1] ^= 1; + assert.throws(() => sealing.open(`${parts.slice(0, 3).join('.')}.${corrupted.toString('base64url')}`, 'auth-token', resource), /open sealed/); + }); + + test('binds credentials to the owner and a one-time handshake generation', async () => { + const sealing = store.add(new MissionControlSealing()); + const identityRequests: { redirect?: RequestRedirect; credential: boolean }[] = []; + const fetcher: typeof fetch = async (_url, init) => { + identityRequests.push({ redirect: init?.redirect, credential: new Headers(init?.headers).get('Authorization') === 'Bearer test-owner-token' }); + return Response.json({ id: 123, type: 'User' }); + }; + const auth = new MissionControlAuthentication(sealing, '123', resource, fetcher, true); + const challenge = (auth.handshakeMeta['copilot.authChallenge'] as { challenge: string }).challenge; + const binding = { challenge, nonce: randomBytes(16).toString('hex'), issuedAt: Math.floor(Date.now() / 1000) }; + const envelope = seal(sealing, 'test-owner-token', binding); + const accepted = await auth.authenticate({ resource, token: envelope }); + await assert.rejects(auth.authenticate({ resource, token: envelope }), /replayed/); + await assert.rejects(auth.authenticate({ resource, token: seal(sealing, 'test-owner-token') }), /connection-bound/); + auth.beginHandshake(); + await assert.rejects(auth.authenticate({ resource, token: seal(sealing, 'test-owner-token', { ...binding, nonce: randomBytes(16).toString('hex') }) }), /binding/); + assert.deepStrictEqual({ accepted, identityRequests }, { + accepted: { resource, token: 'test-owner-token' }, + identityRequests: [{ redirect: 'error', credential: true }], + }); + }); + + test('supports MC pre-sealed tokens in explicit transition mode but refuses another owner', async () => { + const sealing = store.add(new MissionControlSealing()); + const auth = new MissionControlAuthentication(sealing, '123', resource, async () => Response.json({ id: 123, type: 'User' }), false); + const accepted = await auth.authenticate({ resource, token: seal(sealing, 'test-owner-token') }); + const foreign = new MissionControlAuthentication(sealing, '123', resource, async () => Response.json({ id: 456, type: 'User' }), false); + await assert.rejects(foreign.authenticate({ resource, token: seal(sealing, 'other-owner-token') }), /registered owner/); + await assert.rejects(auth.authenticate({ resource, token: seal(sealing, 'test-owner-token', { challenge: 'wrong' }) }), /binding/); + assert.deepStrictEqual({ token: accepted.token, required: (auth.handshakeMeta['copilot.authChallenge'] as { required: boolean }).required }, { + token: 'test-owner-token', required: false, + }); + }); + + test('refuses failed identity validation and non-user principals', async () => { + await assert.rejects(resolveMissionControlOwner(async () => new Response('', { status: 401 }), resource, 'test'), /401/); + await assert.rejects(resolveMissionControlOwner(async () => Response.json({ id: 123, type: 'Bot' }), resource, 'test'), /user identity/); + }); + + for (const { status, headers, code } of [ + { status: 401, headers: new Headers(), code: JsonRpcErrorCodes.InvalidParams }, + { status: 403, headers: new Headers(), code: JsonRpcErrorCodes.InvalidParams }, + { status: 403, headers: new Headers({ 'Retry-After': '10' }), code: JsonRpcErrorCodes.InternalError }, + { status: 403, headers: new Headers({ 'X-RateLimit-Remaining': '0' }), code: JsonRpcErrorCodes.InternalError }, + { status: 429, headers: new Headers(), code: JsonRpcErrorCodes.InternalError }, + { status: 503, headers: new Headers(), code: JsonRpcErrorCodes.InternalError }, + ]) { + test(`identity HTTP ${status} with ${JSON.stringify([...headers])} distinguishes rejection from a host fault`, async () => { + const sealing = store.add(new MissionControlSealing()); + const auth = new MissionControlAuthentication(sealing, '123', resource, + async () => new Response('', { status, headers }), false); + await assert.rejects(auth.authenticate({ resource, token: seal(sealing, 'owner-token') }), { code }); + assert.strictEqual(auth.authenticated, false); + }); + } + + for (const transition of ['handshake', 'close'] as const) { + test(`does not authorize a stale credential after ${transition}`, async () => { + const sealing = store.add(new MissionControlSealing()); + const identity = new DeferredPromise(); + const auth = new MissionControlAuthentication(sealing, '123', resource, () => identity.p, false); + const request = auth.authenticate({ resource, token: seal(sealing, 'test-owner-token') }); + const rejected = assert.rejects(request, /expired handshake/); + if (transition === 'handshake') { + auth.beginHandshake(); + } else { + auth.dispose(); + } + await identity.complete(Response.json({ id: 123, type: 'User' })); + await rejected; + assert.strictEqual(auth.authenticated, false); + }); + } + + test('MCP authentication cannot establish the owner identity or retarget a credential', async () => { + const sealing = store.add(new MissionControlSealing()); + const mcpResource = 'https://mcp.example.test'; + const auth = new MissionControlAuthentication(sealing, '123', resource, async () => Response.json({ id: 123, type: 'User' }), false); + await assert.rejects(auth.authenticate({ resource: mcpResource, token: seal(sealing, 'mcp-token', undefined, 'mcp-auth-token', mcpResource) }), /relay identity/); + await auth.authenticate({ resource, token: seal(sealing, 'owner-token') }); + const accepted = await auth.authenticate({ resource: mcpResource, token: seal(sealing, 'mcp-token', undefined, 'mcp-auth-token', mcpResource) }); + await assert.rejects(auth.authenticate({ resource: 'https://attacker.test', token: seal(sealing, 'mcp-token', undefined, 'mcp-auth-token', mcpResource) }), /context/); + assert.deepStrictEqual({ authorized: auth.authenticated, token: accepted.token }, { authorized: true, token: 'mcp-token' }); + auth.beginHandshake(); + assert.strictEqual(auth.authenticated, false); + }); + + test('validates Enterprise identity at the trusted API base rather than the MC origin', async () => { + const sealing = store.add(new MissionControlSealing()); + const api = 'https://github.enterprise.test/api/v3'; + const requests: string[] = []; + const auth = new MissionControlAuthentication(sealing, '123', api, async input => { + requests.push(input.toString()); + return Response.json({ id: 123, type: 'User' }); + }, false); + await auth.authenticate({ resource: api, token: seal(sealing, 'enterprise-token', undefined, 'auth-token', api) }); + assert.deepStrictEqual({ authorized: auth.authenticated, requests }, { authorized: true, requests: [`${api}/user`] }); + }); + + test('identity-authority changes invalidate an outstanding owner check', async () => { + const sealing = store.add(new MissionControlSealing()); + const identity = new DeferredPromise(); + let current = true; + const auth = new MissionControlAuthentication(sealing, '123', resource, () => identity.p, false, () => current); + const request = auth.authenticate({ resource, token: seal(sealing, 'test-owner-token') }); + const rejected = assert.rejects(request, /expired handshake/); + current = false; + await identity.complete(Response.json({ id: 123, type: 'User' })); + await rejected; + assert.strictEqual(auth.authenticated, false); + }); + + test('concurrently restored MCP credentials wait for the same-handshake identity check', async () => { + const sealing = store.add(new MissionControlSealing()); + const identity = new DeferredPromise(); + const auth = new MissionControlAuthentication(sealing, '123', resource, () => identity.p, false); + const owner = auth.authenticate({ resource, token: seal(sealing, 'owner-token') }); + const mcpResource = 'https://mcp.example.test'; + const mcp = auth.authenticate({ resource: mcpResource, token: seal(sealing, 'mcp-token', undefined, 'mcp-auth-token', mcpResource) }); + let installed = false; + void mcp.then(() => { installed = true; }); + await Promise.resolve(); + const beforeOwnerCheck = installed; + await identity.complete(Response.json({ id: 123, type: 'User' })); + await owner; + const result = await mcp; + assert.deepStrictEqual({ beforeOwnerCheck, authorized: auth.authenticated, token: result.token }, { + beforeOwnerCheck: false, authorized: true, token: 'mcp-token', + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/missionControlHost.test.ts b/src/vs/platform/agentHost/test/node/missionControlHost.test.ts new file mode 100644 index 00000000000000..b83045527a3789 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/missionControlHost.test.ts @@ -0,0 +1,116 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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 { INativeEnvironmentService } from '../../../environment/common/environment.js'; +import { TestInstantiationService } from '../../../instantiation/test/common/instantiationServiceMock.js'; +import { ILogService, NullLogService } from '../../../log/common/log.js'; +import { IProductService } from '../../../product/common/productService.js'; +import { AgentHostClientFileSystemProvider } from '../../common/agentHostClientFileSystemProvider.js'; +import { AgentHostLaunchKind } from '../../common/agentHostTelemetry.js'; +import { IAgentService } from '../../common/agentService.js'; +import { ISessionDataService } from '../../common/sessionDataService.js'; +import { IAgentHostGitHubEndpointService } from '../../node/agentHostGitHubEndpointService.js'; +import { IAgentHostProviderService } from '../../node/agentHostProviderService.js'; +import { IAgentHostProxyResolver } from '../../node/agentHostProxyResolver.js'; +import { AgentHostStateManager, IAgentHostStateManager } from '../../node/agentHostStateManager.js'; +import { MissionControlEnvironment, type IMissionControlEnvironmentHost } from '../../node/missionControl/missionControlEnvironment.js'; +import { getMissionControlEnvironmentName, MissionControlHost } from '../../node/missionControl/missionControlHost.js'; +import { MissionControlProtocolServer } from '../../node/missionControl/missionControlProtocolServer.js'; +import { ProtocolServerHandler, type IProtocolServerConfig } from '../../node/protocolServerHandler.js'; + +suite('Mission Control host integration', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + for (const [quality, nameShort, expected] of [ + ['stable', 'Visual Studio Code', 'VS Code'], + ['insider', 'Visual Studio Code - Insiders', 'VS Code Insiders'], + [undefined, 'Code - OSS', 'VS Code OSS'], + [undefined, 'Code - OSS Dev', 'VS Code OSS'], + ['exploration', 'Code - Exploration', 'Code - Exploration'], + ] as const) { + test(`uses the product display name for ${nameShort}`, () => { + const product = new class extends mock() { + override readonly quality = quality; + override readonly nameShort = nameShort; + }(); + assert.strictEqual(getMissionControlEnvironmentName(product), expected); + }); + } + + function createHost(instantiation = store.add(new TestInstantiationService())) { + const counts = { requests: 0, handlers: 0 }; + instantiation.stub(INativeEnvironmentService, new class extends mock() { + override readonly isBuilt = true; + override readonly userDataPath = '/unused-mission-control-test-profile'; + }()); + instantiation.stub(IProductService, new class extends mock() { + override readonly quality = 'stable'; + override readonly nameShort = 'Visual Studio Code'; + }()); + instantiation.stub(IAgentHostProxyResolver, new class extends mock() { + override async fetch(): Promise { + counts.requests++; + throw new Error('Registration must not start before opt-in'); + } + }()); + instantiation.stub(IAgentHostGitHubEndpointService, new class extends mock() { + override readonly onDidChange = Event.None; + override getApiBaseUri(): string { return 'https://api.github.com'; } + }()); + instantiation.stub(IAgentService, new class extends mock() { }()); + instantiation.stub(IAgentHostStateManager, new class extends mock() { }()); + instantiation.stub(ISessionDataService, new class extends mock() { }()); + instantiation.stub(IAgentHostProviderService, new class extends mock() { }()); + instantiation.stub(ILogService, new NullLogService()); + const host = store.add(instantiation.createInstance(MissionControlHost, { + hostLaunchKind: AgentHostLaunchKind.VSCodeMainProcess, + clientFileSystemProvider: store.add(instantiation.createInstance(AgentHostClientFileSystemProvider)), + trackProtocolHandler: handler => { + counts.handlers++; + return toDisposable(() => handler.dispose()); + }, + })); + return { host, counts }; + } + + test('constructs in a built product without starting registration before opt-in', async () => { + const { host, counts } = createHost(); + await host.environment.configure(undefined); + assert.deepStrictEqual({ + constructed: host.environment instanceof MissionControlEnvironment, + enabled: host.environment.isEnabled, + environmentId: host.environment.environmentId, + ...counts, + }, { constructed: true, enabled: false, environmentId: undefined, requests: 0, handlers: 0 }); + }); + + test('does not advertise or forward host-wide diagnostic logs on Mission Control ingress', () => { + const instantiation = store.add(new TestInstantiationService()); + const creations = sinon.spy(instantiation, 'createInstance'); + store.add(toDisposable(() => creations.restore())); + instantiation.stubInstance(ProtocolServerHandler, new class extends mock() { + override dispose(): void { } + }()); + createHost(instantiation); + const environmentCreation = creations.getCalls().find(call => call.args[0] === MissionControlEnvironment); + assert.ok(environmentCreation); + const options = environmentCreation.args[1] as IMissionControlEnvironmentHost; + const relay = new class extends mock() { }(); + store.add(options.attach(relay, [], () => [])); + const handlerCreation = creations.getCalls().find(call => call.args[0] === ProtocolServerHandler); + assert.ok(handlerCreation); + const config = handlerCreation.args[4] as IProtocolServerConfig; + assert.deepStrictEqual({ + hostManagement: config.allowExtensionMethods, + diagnosticLogs: config.otlpLogEmitter, + }, { hostManagement: false, diagnosticLogs: undefined }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/missionControlProtocolServer.test.ts b/src/vs/platform/agentHost/test/node/missionControlProtocolServer.test.ts new file mode 100644 index 00000000000000..d1f787fcccf3a9 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/missionControlProtocolServer.test.ts @@ -0,0 +1,1319 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { createPublicKey, generateKeyPairSync, randomUUID, sign, verify, type JsonWebKey } from 'crypto'; +import { EventEmitter } from 'events'; +import { copyFile, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'fs/promises'; +import { realpathSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from '../../../../base/common/path.js'; +import { hasKey } from '../../../../base/common/types.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { DeferredPromise } from '../../../../base/common/async.js'; +import sinon from 'sinon'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { MissionControlControlVerifier, type IMissionControlSigningKey } from '../../node/missionControl/missionControlControl.js'; +import { MissionControlProtocolServer, type IMissionControlSocket } from '../../node/missionControl/missionControlProtocolServer.js'; +import { MissionControlEnvironment } from '../../node/missionControl/missionControlEnvironment.js'; +import type { IProtocolTransport } from '../../common/state/sessionTransport.js'; +import { MissionControlSessionMirror } from '../../node/missionControl/missionControlSessionMirror.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { ActionType } from '../../common/state/sessionActions.js'; + +const prefix = 'user.owner.env.environment'; +const order = BigInt('0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551'); + +class FakeWpsSocket extends EventEmitter implements IMissionControlSocket { + readonly publishes: { group: string; data: { kind: string; data: object } }[] = []; + readonly joins: string[] = []; + readonly acknowledgements: number[] = []; + readonly publishAckIds: number[] = []; + readonly userEvents: { readonly event: string; readonly data: object }[] = []; + closed = false; + constructor(private readonly _ackSuccess = true, private readonly _manualPublishAcks = false) { super(); } + + send(data: string): void { + const frame = JSON.parse(data) as { type: string; event?: string; ackId?: number; group?: string; data?: { kind: string; data: object }; sequenceId?: number }; + if (frame.type === 'event' && frame.event && frame.data) { + this.userEvents.push({ event: frame.event, data: frame.data }); + if (frame.ackId) { + this.publishAckIds.push(frame.ackId); + } + } + if (frame.type === 'joinGroup' && frame.group) { + this.joins.push(frame.group); + } + if (frame.type === 'sendToGroup' && frame.group && frame.data) { + this.publishes.push({ group: frame.group, data: frame.data }); + if (frame.ackId) { + this.publishAckIds.push(frame.ackId); + } + } + if (frame.type === 'sequenceAck' && frame.sequenceId) { + this.acknowledgements.push(frame.sequenceId); + } + if (frame.ackId && !(this._manualPublishAcks && (frame.type === 'sendToGroup' || frame.type === 'event'))) { + this.emit('message', JSON.stringify({ type: 'ack', ackId: frame.ackId, success: this._ackSuccess })); + } + } + + deliver(group: string, data: object, sequenceId: number): void { + this.emit('message', JSON.stringify({ type: 'message', from: 'group', group, dataType: 'json', data: { kind: 'message', data }, sequenceId })); + } + + close(): void { + this.closed = true; + } +} + +suite('Mission Control WPS', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function signingFixture(kid = 'test-key') { + const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }); + const key: IMissionControlSigningKey = { ...(publicKey.export({ format: 'jwk' }) as JsonWebKey), kid, kty: 'EC', crv: 'P-256', alg: 'ES256', use: 'sig' }; + const signedControl = (payload: T, nonce: string, environment = 'environment') => { + const header = Buffer.from(JSON.stringify({ typ: 'JWT', alg: 'ES256', kid: key.kid })).toString('base64url'); + const claims = Buffer.from(JSON.stringify({ + iat: Math.floor(Date.now() / 1000), jti: nonce, user_id: 'owner', environment_id: environment, kind: payload.kind, payload, + })).toString('base64url'); + const input = `${header}.${claims}`; + const signature = sign('sha256', Buffer.from(input), { key: privateKey, dsaEncoding: 'ieee-p1363' }); + const s = BigInt(`0x${signature.subarray(32).toString('hex')}`); + if (s > order / 2n) { + Buffer.from((order - s).toString(16).padStart(64, '0'), 'hex').copy(signature, 32); + } + return { ...payload, signature: `${input}.${signature.toString('base64url')}` }; + }; + const signed = (clientId: string, nonce: string, passive = false, environment = 'environment') => + signedControl({ kind: 'spawn_request', client_id: clientId, spawn_request_id: `spawn-${nonce}`, passive }, nonce, environment); + return { key, signed, signedControl }; + } + + test('rejects stale, mismatched, spoofed, and replayed control before opening a lane', () => { + const { key, signed } = signingFixture(); + const verifier = new MissionControlControlVerifier('environment', 'owner', [key]); + const valid = signed('client-a', 'nonce-a'); + const stale = new MissionControlControlVerifier('environment', 'owner', [key], () => Date.now() + 600_000); + const rejection = (action: () => void) => { + try { + action(); + return 'accepted'; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }; + assert.deepStrictEqual({ + first: verifier.verify(valid).client_id, + replay: rejection(() => verifier.verify(valid)), + environment: rejection(() => verifier.verify(signed('client-b', 'nonce-b', false, 'other'))), + spoof: rejection(() => verifier.verify({ ...signed('client-c', 'nonce-c'), passive: true })), + stale: rejection(() => stale.verify(signed('client-d', 'nonce-d'))), + owner: rejection(() => new MissionControlControlVerifier('environment', 'other', [key]).verify(signed('client-e', 'nonce-e'))), + }, { + first: 'client-a', + replay: 'Replay or exhausted control replay window', + environment: 'Control request does not match this environment', + spoof: 'Control request does not match this environment', + stale: 'Control request does not match this environment', + owner: 'Control request does not match this environment', + }); + }); + + for (const kind of ['spawn_request', 'backfill_request'] as const) { + test(`rejects a cryptographically valid high-S ${kind} without consuming the nonce`, () => { + const { key, signedControl } = signingFixture(); + const payload = kind === 'spawn_request' + ? { kind, client_id: 'client-a', spawn_request_id: 'spawn-a', passive: false } + : { kind, environment_id: 'environment', session_id: 'ahp-session:/session', ns: 'ahp', from_seq: 0, to_seq: 0, request_id: 'request-a' }; + const low = signedControl(payload, `nonce-${kind}`); + const [header, claims, encodedSignature] = low.signature.split('.'); + const signature = Buffer.from(encodedSignature, 'base64url'); + const lowS = BigInt(`0x${signature.subarray(32).toString('hex')}`); + Buffer.from((order - lowS).toString(16).padStart(64, '0'), 'hex').copy(signature, 32); + const high = { ...low, signature: `${header}.${claims}.${signature.toString('base64url')}` }; + assert.deepStrictEqual({ + lowS: lowS <= order / 2n, + highS: order - lowS > order / 2n, + validSignature: verify('sha256', Buffer.from(`${header}.${claims}`), { + key: createPublicKey({ key, format: 'jwk' }), dsaEncoding: 'ieee-p1363', + }, signature), + }, { lowS: true, highS: true, validSignature: true }); + const verifier = new MissionControlControlVerifier('environment', 'owner', [key]); + const verifyRequest = kind === 'spawn_request' ? (value: typeof low) => verifier.verify(value) : (value: typeof low) => verifier.verifyBackfill(value); + assert.throws(() => verifyRequest(high), /Invalid Mission Control signature/); + assert.doesNotThrow(() => verifyRequest(low)); + }); + } + + test('failed WPS join rejects the handshake and closes the socket', async () => { + const { key } = signingFixture(); + const socket = new FakeWpsSocket(false); + const errors: Error[] = []; + const server = store.add(new MissionControlProtocolServer( + { url: 'ws://127.0.0.1/fake', access_token: 'fake-token', groups: { control: `${prefix}.control` } }, + 'owner', 'environment', new MissionControlControlVerifier('environment', 'owner', [key]), + () => socket, error => errors.push(error), + )); + const ready = server.connect(); + socket.emit('message', JSON.stringify({ type: 'system', event: 'connected' })); + await assert.rejects(ready, /connection closed before joining/); + assert.deepStrictEqual({ closed: socket.closed, errors: errors.map(error => error.message) }, { + closed: true, errors: ['Mission Control WPS operation rejected'], + }); + }); + + test('reports an unexpected socket close without logging its untrusted reason', async () => { + const { key } = signingFixture(); + const socket = new FakeWpsSocket(); + const errors: Error[] = []; + const server = store.add(new MissionControlProtocolServer( + { url: 'ws://127.0.0.1/fake', access_token: 'fake-token', groups: { control: `${prefix}.control` } }, + 'owner', 'environment', new MissionControlControlVerifier('environment', 'owner', [key]), + () => socket, error => errors.push(error), + )); + const ready = server.connect(); + socket.emit('message', JSON.stringify({ type: 'system', event: 'connected' })); + await ready; + socket.emit('close', 1008, Buffer.from('untrusted closure text')); + socket.emit('close', 1008, Buffer.from('duplicate')); + assert.deepStrictEqual({ closed: socket.closed, errors: errors.map(error => error.message) }, { + closed: true, errors: ['Mission Control WPS socket closed (code 1008)'], + }); + }); + + test('reports a socket error once', async () => { + const { key } = signingFixture(); + const socket = new FakeWpsSocket(); + const errors: Error[] = []; + const server = store.add(new MissionControlProtocolServer( + { url: 'ws://127.0.0.1/fake', access_token: 'fake-token', groups: { control: `${prefix}.control` } }, + 'owner', 'environment', new MissionControlControlVerifier('environment', 'owner', [key]), + () => socket, error => errors.push(error), + )); + const ready = server.connect(); + socket.emit('message', JSON.stringify({ type: 'system', event: 'connected' })); + await ready; + const failure = new Error('Socket connection failed'); + socket.emit('error', failure); + socket.emit('close', 1006); + server.dispose(); + assert.deepStrictEqual({ closed: socket.closed, errors }, { closed: true, errors: [failure] }); + }); + + test('does not report socket events after intentional disposal', async () => { + const { key } = signingFixture(); + const socket = new FakeWpsSocket(); + const errors: Error[] = []; + const server = store.add(new MissionControlProtocolServer( + { url: 'ws://127.0.0.1/fake', access_token: 'fake-token', groups: { control: `${prefix}.control` } }, + 'owner', 'environment', new MissionControlControlVerifier('environment', 'owner', [key]), + () => socket, error => errors.push(error), + )); + const ready = server.connect(); + socket.emit('message', JSON.stringify({ type: 'system', event: 'connected' })); + await ready; + server.dispose(); + socket.emit('close', 1000); + socket.emit('error', new Error('After disposal')); + assert.deepStrictEqual({ closed: socket.closed, errors }, { closed: true, errors: [] }); + }); + + test('signing-key rotation revokes the removed key without resetting replay protection', () => { + const original = signingFixture(); + const rotated = signingFixture('rotated-key'); + const verifier = new MissionControlControlVerifier('environment', 'owner', [original.key]); + const accepted = original.signed('client-a', 'already-seen'); + verifier.verify(accepted); + verifier.updateKeys([original.key, rotated.key]); + assert.throws(() => verifier.verify(accepted), /Replay/); + verifier.verify(rotated.signed('client-b', 'new-key')); + verifier.updateKeys([rotated.key]); + assert.throws(() => verifier.verify(original.signed('client-c', 'removed-key')), /Untrusted/); + }); + + test('publishes a closure after teardown and gives a successor a different generation', async () => { + const { key, signed } = signingFixture(); + const socket = new FakeWpsSocket(); + const server = store.add(new MissionControlProtocolServer( + { url: 'ws://127.0.0.1/fake', access_token: 'fake-token', groups: { control: `${prefix}.control` } }, + 'owner', 'environment', new MissionControlControlVerifier('environment', 'owner', [key]), () => socket, + )); + const lanes: IProtocolTransport[] = []; + let teardownObserved = false; + store.add(server.onConnection(lane => { + lanes.push(lane); + store.add(lane.onClose(() => { teardownObserved = true; })); + store.add(lane.onMessage(message => lane.send({ jsonrpc: '2.0', id: hasKey(message, { id: true }) ? message.id : 0, result: null }))); + })); + const ready = server.connect(); + socket.emit('message', JSON.stringify({ type: 'system', event: 'connected' })); + await ready; + socket.deliver(`${prefix}.control`, signed('client-a', 'generation'), 1); + socket.deliver(`${prefix}.client.client-a.to-host`, { jsonrpc: '2.0', id: 1, method: 'initialize', params: { clientId: 'client-a' } }, 2); + lanes[0].dispose(); + assert.strictEqual(teardownObserved, true); + socket.deliver(`${prefix}.client.client-a.to-host`, { jsonrpc: '2.0', id: 2, method: 'reconnect', params: { clientId: 'client-a' } }, 3); + const frames = socket.publishes.map(frame => frame.data as { kind: string; generation?: number }); + assert.deepStrictEqual(frames.map(frame => frame.kind), ['message', 'closed', 'message']); + assert.strictEqual(frames[0].generation, frames[1].generation); + assert.ok(Number.isSafeInteger(frames[0].generation)); + assert.notStrictEqual(frames[0].generation, frames[2].generation); + assert.strictEqual(lanes.length, 2); + }); + + test('reclaims a silent lane after the advertised window without a closure notice', async () => { + const clock = sinon.useFakeTimers(); + const { key, signed } = signingFixture(); + const socket = new FakeWpsSocket(); + const server = store.add(new MissionControlProtocolServer( + { url: 'ws://127.0.0.1/fake', access_token: 'fake-token', groups: { control: `${prefix}.control` } }, + 'owner', 'environment', new MissionControlControlVerifier('environment', 'owner', [key]), () => socket, + )); + try { + let lane: IProtocolTransport | undefined; + let closed = false; + store.add(server.onConnection(value => { lane = value; store.add(value.onClose(() => { closed = true; })); })); + const ready = server.connect(); + socket.emit('message', JSON.stringify({ type: 'system', event: 'connected' })); + await ready; + socket.deliver(`${prefix}.control`, signed('client-a', 'idle'), 1); + const timeout = lane?.relayHandshakeMeta?.['copilot.keepAliveTimeoutMs']; + assert.strictEqual(timeout, 900_000); + await clock.tickAsync(899_999); + assert.strictEqual(closed, false); + await clock.tickAsync(1); + assert.strictEqual(closed, true); + assert.strictEqual(socket.publishes.length, 0); + } finally { + server.dispose(); + clock.restore(); + } + }); + + test('serializes AHP publishes behind acknowledgements without reordering', async () => { + const { key, signed } = signingFixture(); + const socket = new FakeWpsSocket(true, true); + const server = store.add(new MissionControlProtocolServer( + { url: 'ws://127.0.0.1/fake', access_token: 'fake-token', groups: { control: `${prefix}.control` } }, + 'owner', 'environment', new MissionControlControlVerifier('environment', 'owner', [key]), () => socket, + )); + store.add(server.onConnection(lane => { + lane.send({ jsonrpc: '2.0', id: 1, result: null }); + lane.send({ jsonrpc: '2.0', id: 2, result: null }); + })); + const ready = server.connect(); + socket.emit('message', JSON.stringify({ type: 'system', event: 'connected' })); + await ready; + socket.deliver(`${prefix}.control`, signed('client-a', 'serial'), 1); + const beforeAck = socket.publishes.length; + socket.emit('message', JSON.stringify({ type: 'ack', ackId: socket.publishAckIds[0], success: true })); + assert.deepStrictEqual({ beforeAck, ids: socket.publishes.map(frame => (frame.data.data as { id: number }).id) }, { + beforeAck: 1, ids: [1, 2], + }); + }); + + test('overlapping handshakes replace the pending connection instead of leaving a stuck lane', async () => { + const { key, signed } = signingFixture(); + const socket = new FakeWpsSocket(); + const server = store.add(new MissionControlProtocolServer( + { url: 'ws://127.0.0.1/fake', access_token: 'fake-token', groups: { control: `${prefix}.control` } }, + 'owner', 'environment', new MissionControlControlVerifier('environment', 'owner', [key]), () => socket, + )); + const lanes: IProtocolTransport[] = []; + store.add(server.onConnection(lane => { + lanes.push(lane); + store.add(lane.onMessage(message => { + if (hasKey(message, { id: true }) && message.id === 2) { + lane.send({ jsonrpc: '2.0', id: 2, result: {} }); + } + })); + })); + const ready = server.connect(); + socket.emit('message', JSON.stringify({ type: 'system', event: 'connected' })); + await ready; + socket.deliver(`${prefix}.control`, signed('client-a', 'overlap'), 1); + socket.deliver(`${prefix}.client.client-a.to-host`, { jsonrpc: '2.0', id: 1, method: 'initialize', params: { clientId: 'client-a' } }, 2); + socket.deliver(`${prefix}.client.client-a.to-host`, { jsonrpc: '2.0', id: 2, method: 'initialize', params: { clientId: 'client-a' } }, 3); + assert.strictEqual(lanes.length, 2); + assert.deepStrictEqual(socket.publishes.map(frame => frame.data.kind), ['closed', 'message']); + }); + + test('mirrors authoritative actions as user events and accepts only signed retained backfill', async () => { + const clock = sinon.useFakeTimers(); + try { + const { key, signedControl } = signingFixture(); + const socket = new FakeWpsSocket(); + const mirror = store.add(new MissionControlSessionMirror('environment', {}, new NullLogService())); + const session = 'ahp-session:/mirror-test'; + mirror.registerSession(session); + const server = store.add(new MissionControlProtocolServer( + { url: 'ws://127.0.0.1/fake', access_token: 'fake-token', groups: { control: `${prefix}.control`, ingest_ack: `${prefix}.ingest-ack` } }, + 'owner', 'environment', new MissionControlControlVerifier('environment', 'owner', [key]), () => socket, undefined, undefined, undefined, mirror, + )); + const ready = server.connect(); + socket.emit('message', JSON.stringify({ type: 'system', event: 'connected' })); + await ready; + store.add(mirror.attach(event => server.publishMirrorEvent(event))); + mirror.enqueue({ channel: session, serverSeq: 7, origin: undefined, action: { type: ActionType.SessionTitleChanged, title: 'Mirrored title' } }, session); + await clock.tickAsync(2); + const original = socket.userEvents[0]; + assert.strictEqual(original.event, 'sessionEvents'); + assert.strictEqual(mirror.getSessionStatus(session).retainedFrames, 1); + const request = signedControl({ kind: 'backfill_request', environment_id: 'environment', session_id: session, ns: 'ahp', from_seq: 0, to_seq: 0, request_id: 'backfill-test' }, 'backfill-nonce'); + socket.deliver(`${prefix}.control`, request, 1); + await clock.tickAsync(2); + assert.deepStrictEqual(socket.userEvents[1], original); + socket.deliver(`${prefix}.ingest-ack`, { watermarks: { [session]: { ahp: 0 } } }, 2); + assert.strictEqual(mirror.getSessionStatus(session).retainedFrames, 0); + assert.deepStrictEqual(socket.joins, [`${prefix}.control`, `${prefix}.ingest-ack`]); + server.dispose(); + mirror.dispose(); + } finally { + clock.restore(); + } + }); + + for (const { name, count, title } of [ + { name: 'frame count', count: 600, title: 'Title' }, + { name: 'byte budget', count: 48, title: 'x'.repeat(512 * 1024) }, + ]) { + test(`backpressures historical mirror ${name} without disconnecting interactive lanes`, async () => { + const clock = sinon.useFakeTimers(); + const { key, signed } = signingFixture(); + const socket = new FakeWpsSocket(true, true); + const mirror = store.add(new MissionControlSessionMirror('environment', {}, new NullLogService())); + const session = 'ahp-session:/large-mirror'; + mirror.registerSession(session); + for (let index = 0; index < count; index++) { + assert.strictEqual(mirror.enqueue({ channel: session, serverSeq: index, origin: undefined, action: { type: ActionType.SessionTitleChanged, title: `${title} ${index}` } }, session), true); + } + const server = store.add(new MissionControlProtocolServer( + { url: 'ws://127.0.0.1/fake', access_token: 'fake-token', groups: { control: `${prefix}.control`, ingest_ack: `${prefix}.ingest-ack` } }, + 'owner', 'environment', new MissionControlControlVerifier('environment', 'owner', [key]), + () => socket, undefined, undefined, undefined, mirror, + )); + store.add(server.onConnection(lane => { + store.add(lane.onMessage(() => lane.send({ jsonrpc: '2.0', id: 1, result: null }))); + })); + try { + const ready = server.connect(); + socket.emit('message', JSON.stringify({ type: 'system', event: 'connected' })); + await ready; + store.add(mirror.attach(event => server.publishMirrorEvent(event))); + await clock.tickAsync(5); + const beforeDrain = { sent: socket.userEvents.length, retained: mirror.getSessionStatus(session).retainedFrames, closed: server.isClosed }; + socket.deliver(`${prefix}.control`, signed('client-a', 'mirror-pressure'), 1); + socket.deliver(`${prefix}.client.client-a.to-host`, { jsonrpc: '2.0', id: 1, method: 'initialize', params: { clientId: 'client-a' } }, 2); + for (let index = 0; index < count + 2 && socket.userEvents.length < count; index++) { + const ackId = socket.publishAckIds[index]; + assert.ok(ackId !== undefined, 'Mirror must resume when transport capacity becomes available'); + socket.emit('message', JSON.stringify({ type: 'ack', ackId, success: true })); + await clock.tickAsync(1); + } + assert.deepStrictEqual({ + beforeDrain, mirrored: socket.userEvents.length, replies: socket.publishes.length, + retained: mirror.getSessionStatus(session).retainedFrames, failure: mirror.getSessionStatus(session).failure, closed: server.isClosed, + }, { + beforeDrain: { sent: 1, retained: count, closed: false }, mirrored: count, replies: 1, + retained: count, failure: undefined, closed: false, + }); + } finally { + server.dispose(); + mirror.dispose(); + clock.restore(); + } + }); + } + + test('failed relay startup marks the registered environment offline', async () => { + const path = await mkdtemp(join(tmpdir(), 'mission-control-failed-start-')); + try { + const { key } = signingFixture(); + const socket = new FakeWpsSocket(false); + const requests: { path: string; status?: unknown; name?: string }[] = []; + const fakeFetch: typeof fetch = async (input, init) => { + const url = new URL(input.toString()); + const body = init?.body ? JSON.parse(init.body.toString()) as { status?: string; name?: string } : undefined; + requests.push({ path: url.pathname, status: body?.status, name: body?.name }); + return Response.json(url.pathname.endsWith('jwks.json') ? { keys: [key] } : { + id: 'environment', kind: 'user-local', user_id: 'owner', owner_id: 'owner', owner_type: 'user', + webpubsub: { url: 'ws://127.0.0.1/fake', access_token: 'fake-wps-token', subprotocol: 'json.reliable.webpubsub.azure.v1', groups: { control: `${prefix}.control` } }, + }); + }; + const service = store.add(new MissionControlEnvironment({ + userDataPath: path, + name: 'VS Code OSS', + fetch: fakeFetch, + attach: () => ({ dispose() { } }), + onError: () => { }, + socketFactory: () => { + queueMicrotask(() => socket.emit('message', JSON.stringify({ type: 'system', event: 'connected' }))); + return socket; + } + } + )); + await assert.rejects(service.configure({ baseUrl: 'http://127.0.0.1:9999/', accountId: 'owner', credential: 'fake-local-token', roots: [path] }), /connection closed before joining/); + assert.deepStrictEqual({ requests, closed: socket.closed }, { + requests: [ + { path: '/cmc_internal/api/agents/environments/register', status: undefined, name: 'VS Code OSS' }, + { path: '/cmc_internal/api/agents/environments/.well-known/jwks.json', status: undefined, name: undefined }, + { path: '/cmc_internal/api/agents/environments/environment/heartbeat', status: 'offline', name: 'VS Code OSS' }, + ], + closed: true, + }); + } finally { + await rm(path, { recursive: true }); + } + }); + + test('joins verified client lanes, binds initialize, and publishes responses on to-client', async () => { + const { key, signed } = signingFixture(); + const socket = new FakeWpsSocket(); + const errors: Error[] = []; + const server = store.add(new MissionControlProtocolServer( + { url: 'ws://127.0.0.1/fake', access_token: 'fake-token', groups: { control: `${prefix}.control` } }, + 'owner', 'environment', new MissionControlControlVerifier('environment', 'owner', [key]), + () => socket, error => errors.push(error), + )); + const lanes: IProtocolTransport[] = []; + store.add(server.onConnection(lane => { + lanes.push(lane); + store.add(lane.onMessage(message => lane.send({ jsonrpc: '2.0', id: hasKey(message, { id: true }) ? message.id : 0, result: null }))); + })); + const ready = server.connect(); + socket.emit('message', JSON.stringify({ type: 'system', event: 'connected' })); + await ready; + socket.deliver(`${prefix}.control`, signed('client-a', 'nonce-a'), 1); + const toHost = `${prefix}.client.client-a.to-host`; + socket.deliver(toHost, { jsonrpc: '2.0', id: 1, method: 'initialize', params: { clientId: 'wrong' } }, 2); + socket.deliver(`${prefix}.control`, signed('client-b', 'nonce-b', true), 3); + socket.deliver(`${prefix}.client.client-b.to-host`, { jsonrpc: '2.0', id: 2, method: 'ping', params: {} }, 4); + socket.deliver(`${prefix}.client.client-b.to-host`, { jsonrpc: '2.0', id: 2, method: 'ping', params: {} }, 4); + let credentialLaneClosed = false; + store.add(lanes[1].onClose(() => { credentialLaneClosed = true; })); + socket.deliver(`${prefix}.client.client-b.to-host`, { jsonrpc: '2.0', id: 3, method: 'authenticate', params: { token: 'fake-sensitive-token' } }, 5); + assert.deepStrictEqual({ + joins: socket.joins, + firstClosed: socket.closed, + secondPassive: lanes[1].relayPassive, + publishes: socket.publishes.map(frame => frame.group), + acks: socket.acknowledgements, + errors: errors.map(error => error.message), + credentialLaneClosed, + credentialPublished: socket.publishes.some(frame => JSON.stringify(frame).includes('fake-sensitive-token')), + }, { + joins: [`${prefix}.control`, toHost, `${prefix}.client.client-b.to-host`], + firstClosed: false, + secondPassive: true, + publishes: [`${prefix}.client.client-a.to-client`, `${prefix}.client.client-b.to-client`, `${prefix}.client.client-b.to-client`], + acks: [1, 2, 3, 4, 4, 5], + errors: [], + credentialLaneClosed: true, + credentialPublished: false, + }); + }); + + test('fresh signed spawn retries reuse the lane without changing its role', async () => { + const { key, signed } = signingFixture(); + const socket = new FakeWpsSocket(); + const errors: string[] = []; + const server = store.add(new MissionControlProtocolServer( + { url: 'ws://127.0.0.1/fake', access_token: 'fake-token', groups: { control: `${prefix}.control` } }, + 'owner', 'environment', new MissionControlControlVerifier('environment', 'owner', [key]), + () => socket, error => errors.push(error.message), + )); + const lanes: IProtocolTransport[] = []; + store.add(server.onConnection(lane => lanes.push(lane))); + const ready = server.connect(); + socket.emit('message', JSON.stringify({ type: 'system', event: 'connected' })); + await ready; + socket.deliver(`${prefix}.control`, signed('client-a', 'first'), 1); + socket.deliver(`${prefix}.control`, signed('client-a', 'retry'), 2); + socket.deliver(`${prefix}.control`, signed('client-a', 'changed-role', true), 3); + assert.deepStrictEqual({ laneCount: lanes.length, joins: socket.joins, passive: lanes[0].relayPassive, errors }, { + laneCount: 1, + joins: [`${prefix}.control`, `${prefix}.client.client-a.to-host`], + passive: false, + errors: ['Mission Control cannot change the role of an existing client lane'], + }); + }); + + test('registers the process identity, heartbeats, and refuses account rebinding', async () => { + const path = await mkdtemp(join(process.cwd(), '.build', 'mission-control-test-')); + const clock = sinon.useFakeTimers(); + try { + const { key } = signingFixture(); + const sockets: FakeWpsSocket[] = []; + const errors: string[] = []; + const requests: { path: string; bearer: boolean; body: Record | undefined }[] = []; + const environment = { + id: 'environment', kind: 'user-local', user_id: 'owner', owner_id: 'owner', owner_type: 'user', + webpubsub: { url: 'ws://127.0.0.1/fake', access_token: 'fake-wps-token', subprotocol: 'json.reliable.webpubsub.azure.v1', groups: { control: `${prefix}.control` } }, + }; + const fakeFetch: typeof fetch = async (input, init) => { + const url = new URL(input.toString()); + requests.push({ + path: url.pathname, + bearer: init?.headers !== undefined && Object.hasOwn(init.headers, 'Authorization'), + body: init?.body ? JSON.parse(init.body.toString()) as Record : undefined, + }); + return Response.json(url.pathname.endsWith('jwks.json') ? { keys: [key] } + : url.pathname.endsWith('/token') ? { ...environment.webpubsub, wps_endpoint: environment.webpubsub.url, expires_at: new Date(clock.now + 120_000).toISOString() } : environment); + }; + const service = store.add(new MissionControlEnvironment({ + userDataPath: path, + name: 'VS Code OSS', + fetch: fakeFetch, + attach: () => ({ dispose() { } }), + onError: error => errors.push(error instanceof Error ? error.message : String(error)), + socketFactory: () => { + const socket = new FakeWpsSocket(); + sockets.push(socket); + queueMicrotask(() => socket.emit('message', JSON.stringify({ type: 'system', event: 'connected' }))); + return socket; + } + } + )); + const options = { baseUrl: 'http://127.0.0.1:9999/', accountId: 'owner', credential: 'fake-local-token', roots: [path] }; + await service.configure(options); + await clock.tickAsync(60_000); + sockets[0].emit('close', 1006); + await clock.tickAsync(60_000); + const persisted = (JSON.parse(await readFile(join(path, 'agent-host-mission-control-id'), 'utf8')) as { id: string }).id; + const rebind = await service.configure({ ...options, accountId: 'other' }).then(() => 'accepted', () => 'rejected'); + await service.configure(undefined); + assert.deepStrictEqual({ + requests: requests.map(request => [request.path, request.bearer, request.body?.kind, request.body?.compute_id]), + persisted: /^[0-9a-f-]{36}$/.test(persisted), + rebind, + controlJoined: sockets.flatMap(socket => socket.joins), + offline: requests.at(-1)?.body?.status, + closed: sockets.every(socket => socket.closed), + errors, + }, { + requests: [ + ['/cmc_internal/api/agents/environments/register', true, 'user-local', persisted], + ['/cmc_internal/api/agents/environments/.well-known/jwks.json', true, undefined, undefined], + ['/cmc_internal/api/agents/environments/environment/heartbeat', true, undefined, undefined], + ['/cmc_internal/api/agents/environments/environment/token', true, undefined, undefined], + ['/cmc_internal/api/agents/environments/environment/heartbeat', true, undefined, undefined], + ['/cmc_internal/api/agents/environments/environment/heartbeat', true, undefined, undefined], + ], + persisted: true, + rebind: 'rejected', + controlJoined: [`${prefix}.control`, `${prefix}.control`], + offline: 'offline', + closed: true, + errors: ['Mission Control WPS socket closed (code 1006)'], + }); + service.dispose(); + } finally { + clock.restore(); + await rm(path, { recursive: true }); + } + }); + + suite('heartbeat Retry-After', () => { + async function withEnvironment( + replies: readonly { retryAfter?: string; status?: number; body?: string }[], + run: (fixture: { + service: MissionControlEnvironment; + clock: sinon.SinonFakeTimers; + heartbeats: { time: number; status: string }[]; + errors: string[]; + sockets: FakeWpsSocket[]; + options: { baseUrl: string; live: boolean; accountId: string; credential: string; roots: string[] }; + changeIdentityAuthority: (base: string) => void; + tokens: number[]; + directory: string; + attachments: { initialRoots: readonly string[]; getRoots: () => readonly string[] }[]; + requests: { path: string; credential: string | null; body?: Record }[]; + delayHeartbeat: () => { started: Promise; complete: (response?: Response) => Promise }; + delayIdentity: () => { started: Promise; complete: (response: Response) => Promise }; + changePolicy: (policy: Record | undefined) => Promise; + }) => Promise, + bootstrapLifetime?: number, + openWorkspace = false, + ): Promise { + const path = await mkdtemp(join(process.cwd(), '.build', 'mission-control-retry-after-')); + const clock = sinon.useFakeTimers({ now: Date.UTC(2026, 9, 2), toFake: ['Date', 'setTimeout', 'clearTimeout'] }); + let service: MissionControlEnvironment | undefined; + try { + const { key } = signingFixture(); + const heartbeats: { time: number; status: string }[] = []; + const errors: string[] = []; + const sockets: FakeWpsSocket[] = []; + const tokens: number[] = []; + const requests: { path: string; credential: string | null; body?: Record }[] = []; + const attachments: { initialRoots: readonly string[]; getRoots: () => readonly string[] }[] = []; + let delayedHeartbeat: { started: DeferredPromise; response: DeferredPromise } | undefined; + let delayedIdentity: { started: DeferredPromise; response: DeferredPromise } | undefined; + let policy: Record | undefined; + let policyReported: DeferredPromise | undefined; + const policyChanged = store.add(new Emitter()); + let identityApiBase = 'https://api.github.com'; + const identityAuthorityChanged = store.add(new Emitter()); + const environment = { + id: 'environment', kind: 'user-local', user_id: '123', owner_id: '123', owner_type: 'user', + webpubsub: { url: 'wss://wps.test/client/hubs/test', access_token: 'fake-token', subprotocol: 'json.reliable.webpubsub.azure.v1', groups: { control: 'user.123.env.environment.control' }, ...(bootstrapLifetime === undefined ? {} : { expires_at: new Date(clock.now + bootstrapLifetime).toISOString() }) }, + }; + service = store.add(new MissionControlEnvironment({ + userDataPath: path, + name: 'VS Code OSS', + fetch: async (input, init) => { + const url = new URL(input.toString()); + requests.push({ path: url.pathname, credential: new Headers(init?.headers).get('Authorization'), body: init?.body ? JSON.parse(String(init.body)) as Record : undefined }); + if (url.pathname.endsWith('/register')) { + await policyReported?.complete(); + policyReported = undefined; + } + if (url.pathname === '/user' && delayedIdentity) { + const delayed = delayedIdentity; + delayedIdentity = undefined; + await delayed.started.complete(); + return delayed.response.p; + } + if (url.pathname.endsWith('/token')) { + tokens.push(clock.now - Date.UTC(2026, 9, 2)); + return Response.json({ ...environment.webpubsub, wps_endpoint: environment.webpubsub.url, expires_at: new Date(clock.now + 120_000).toISOString() }); + } + if (url.pathname.endsWith('/heartbeat')) { + const reply = replies[heartbeats.length]; + heartbeats.push({ time: clock.now - Date.UTC(2026, 9, 2), status: (JSON.parse(String(init?.body)) as { status: string }).status }); + if (delayedHeartbeat) { + const delayed = delayedHeartbeat; + delayedHeartbeat = undefined; + await delayed.started.complete(); + return delayed.response.p; + } + const responseOptions: ResponseInit = { status: reply?.status ?? 200, headers: reply?.retryAfter === undefined ? {} : { 'rEtRy-AfTeR': reply.retryAfter } }; + return reply?.body === undefined ? Response.json(environment, responseOptions) : new Response(reply.body, responseOptions); + } + return Response.json(url.pathname === '/user' ? { id: 123, type: 'User' } : url.pathname.endsWith('/jwks.json') ? { keys: [key] } : environment); + }, + attach: (_server, initialRoots, getRoots) => { + attachments.push({ initialRoots, getRoots }); + return { dispose() { } }; + }, + onError: error => errors.push(error instanceof Error ? error.message : String(error)), + socketFactory: () => { + const socket = new FakeWpsSocket(); + sockets.push(socket); + queueMicrotask(() => socket.emit('message', JSON.stringify({ type: 'system', event: 'connected' }))); + return socket; + }, + getRemoteControlPolicy: async () => policy, + getIdentityApiBase: () => identityApiBase, + onDidChangeIdentityAuthority: identityAuthorityChanged.event, + onDidChangeRemoteControlPolicy: policyChanged.event + } + )); + const options = { baseUrl: 'https://api.github.com', live: true, accountId: '123', credential: 'fake-token', roots: openWorkspace ? [path] : [] }; + await service.configure(options); + await run({ + service, clock, heartbeats, errors, sockets, options, tokens, directory: path, attachments, requests, + delayHeartbeat: () => { + const started = new DeferredPromise(); + const response = new DeferredPromise(); + delayedHeartbeat = { started, response }; + return { started: started.p, complete: value => response.complete(value ?? Response.json(environment)) }; + }, + delayIdentity: () => { + const started = new DeferredPromise(); + const response = new DeferredPromise(); + delayedIdentity = { started, response }; + return { started: started.p, complete: value => response.complete(value) }; + }, + changePolicy: value => { + policy = value; + const reported = new DeferredPromise(); + policyReported = reported; + policyChanged.fire(); + return reported.p; + }, + changeIdentityAuthority: base => { + identityApiBase = base; + identityAuthorityChanged.fire(); + }, + }); + } finally { + service?.dispose(); + clock.restore(); + await rm(path, { recursive: true }); + } + } + + test('rotates expiring access tokens during load shedding without reconnecting a healthy socket', async () => { + await withEnvironment([{ retryAfter: '300' }], async ({ clock, heartbeats, errors, sockets, tokens }) => { + await clock.tickAsync(89_999); + assert.deepStrictEqual(tokens, []); + await clock.tickAsync(1); + assert.deepStrictEqual({ tokens, heartbeats, sockets: sockets.length, errors }, { + tokens: [90_000], heartbeats: [{ time: 0, status: 'online' }], sockets: 1, errors: [], + }); + }, 120_000); + }); + + test('refreshes the host-owned name on startup, periodic, recovery, and withdrawal heartbeats', async () => { + await withEnvironment([], async ({ service, clock, requests, sockets, options }) => { + const hostName = 'VS Code OSS'; + const snapshot = () => { + let storedName: string | undefined; + let registrations = 0; + for (const request of requests) { + if (request.path.endsWith('/register')) { + storedName = `${hostName}-assigned-${++registrations}`; + } else if (request.path.endsWith('/heartbeat') && typeof request.body?.name === 'string') { + storedName = request.body.name; + } + } + return storedName; + }; + const startup = snapshot(); + await clock.tickAsync(60_000); + const periodic = snapshot(); + sockets[0].emit('close', 1006); + await clock.tickAsync(1000); + const recovery = snapshot(); + await service.configure(undefined); + const withdrawal = snapshot(); + await service.configure(options); + assert.deepStrictEqual({ + startup, periodic, recovery, withdrawal, reregistration: snapshot(), + registrations: requests.filter(request => request.path.endsWith('/register')).map(request => request.body?.name), + heartbeatNames: [...new Set(requests.filter(request => request.path.endsWith('/heartbeat')).map(request => request.body?.name))], + }, { + startup: hostName, periodic: hostName, recovery: hostName, withdrawal: hostName, reregistration: hostName, + registrations: [hostName, hostName], heartbeatNames: [hostName], + }); + }); + }); + + for (const scenario of [ + { name: 'delta-seconds on the initial online heartbeat', value: '120', delay: 120_000 }, + { name: 'HTTP-date', value: new Date(Date.UTC(2026, 9, 2) + 120_000).toUTCString(), delay: 120_000 }, + { name: 'long waits without timer overflow', value: '2147484', delay: 2_147_484_000 }, + { name: 'the default interval when absent', value: undefined, delay: 60_000 }, + { name: 'the default minimum for short delays', value: '10', delay: 60_000 }, + { name: 'zero', value: '0', delay: 60_000 }, + { name: 'a past HTTP-date', value: new Date(Date.UTC(2026, 9, 1)).toUTCString(), delay: 60_000 }, + { name: 'negative values', value: '-1', delay: 60_000, invalid: true }, + { name: 'fractional seconds', value: '1.5', delay: 60_000, invalid: true }, + { name: 'malformed values', value: 'not-a-delay', delay: 60_000, invalid: true }, + ]) { + test(`honors ${scenario.name} and resumes the default cadence`, async () => { + await withEnvironment([{ retryAfter: scenario.value }], async ({ clock, heartbeats, errors }) => { + await clock.tickAsync(scenario.delay - 1); + const beforeDeadline = heartbeats.length; + await clock.tickAsync(1); + await clock.tickAsync(59_999); + const beforeDefaultInterval = heartbeats.length; + await clock.tickAsync(1); + assert.deepStrictEqual({ beforeDeadline, beforeDefaultInterval, heartbeats, errors }, { + beforeDeadline: 1, + beforeDefaultInterval: 2, + heartbeats: [{ time: 0, status: 'online' }, { time: scenario.delay, status: 'online' }, { time: scenario.delay + 60_000, status: 'online' }], + errors: scenario.invalid ? ['Mission Control heartbeat returned an invalid Retry-After header'] : [], + }); + }); + }); + } + + for (const response of [ + { name: '429', status: 429, error: 'Mission Control request failed (429)' }, + { name: '503', status: 503, error: 'Mission Control request failed (503)' }, + { name: 'invalid JSON on a successful response', status: 200, error: 'Mission Control returned invalid JSON' }, + ]) { + test(`honors the header before handling ${response.name}`, async () => { + await withEnvironment([{}, { status: response.status, retryAfter: '240', body: 'not-json' }], async ({ clock, heartbeats, errors }) => { + await clock.tickAsync(60_000); + await clock.tickAsync(239_999); + const beforeDeadline = heartbeats.length; + await clock.tickAsync(1); + assert.deepStrictEqual({ beforeDeadline, heartbeats, errors }, { + beforeDeadline: 2, + heartbeats: [{ time: 0, status: 'online' }, { time: 60_000, status: 'online' }, { time: 300_000, status: 'online' }], + errors: [response.error], + }); + }); + }); + } + + test('relay recovery does not bypass the heartbeat delay for its online update', async () => { + await withEnvironment([{ retryAfter: '240' }], async ({ clock, heartbeats, errors, sockets }) => { + sockets[0].emit('close', 1006); + await clock.tickAsync(500); + const socketsAfterRecovery = sockets.length; + await clock.tickAsync(239_499); + const beforeDeadline = heartbeats.length; + await clock.tickAsync(1); + assert.deepStrictEqual({ socketsAfterRecovery, beforeDeadline, heartbeats, errors }, { + socketsAfterRecovery: 2, + beforeDeadline: 1, + heartbeats: [{ time: 0, status: 'online' }, { time: 240_000, status: 'online' }], + errors: ['Mission Control WPS socket closed (code 1006)'], + }); + }); + }); + + test('disable and re-enable preserve the delay, including the offline heartbeat', async () => { + await withEnvironment([{ retryAfter: '300' }], async ({ service, clock, heartbeats, errors, options }) => { + await clock.tickAsync(60_000); + await service.configure(undefined); + await clock.tickAsync(60_000); + const whileDisabled = heartbeats.length; + await service.configure(options); + await clock.tickAsync(179_999); + const beforeDeadline = heartbeats.length; + await clock.tickAsync(1); + await service.configure(undefined); + await clock.tickAsync(300_000); + assert.deepStrictEqual({ whileDisabled, beforeDeadline, heartbeats, errors }, { + whileDisabled: 1, + beforeDeadline: 1, + heartbeats: [{ time: 0, status: 'online' }, { time: 300_000, status: 'online' }, { time: 300_000, status: 'offline' }], + errors: [], + }); + }); + }); + + test('changing the GitHub authority closes the relay immediately and withdraws registration', async () => { + await withEnvironment([], async ({ service, clock, heartbeats, errors, sockets, changeIdentityAuthority }) => { + changeIdentityAuthority('https://api.enterprise.test'); + const closedImmediately = sockets.every(socket => socket.closed); + await service.configure(undefined); + const afterWithdrawal = heartbeats.length; + await clock.tickAsync(120_000); + assert.deepStrictEqual({ closedImmediately, afterWithdrawal, heartbeats, errors }, { + closedImmediately: true, + afterWithdrawal: 2, + heartbeats: [{ time: 0, status: 'online' }, { time: 0, status: 'offline' }], + errors: [], + }); + }); + }); + + for (const status of [401, 403, 503]) { + test(`credential refresh fences an old in-flight ${status} without losing Retry-After or suspending the new configuration`, async () => { + await withEnvironment([], async ({ service, clock, options, errors, sockets, heartbeats, delayHeartbeat, requests }) => { + const delayed = delayHeartbeat(); + await clock.tickAsync(60_000); + await delayed.started; + await service.configure({ ...options, credential: 'refreshed-token' }); + const beforeOldReply = { sockets: sockets.length, closed: sockets.map(socket => socket.closed) }; + await delayed.complete(new Response('{}', { status, headers: { 'Retry-After': '240' } })); + await clock.tickAsync(239_999); + const beforeDeadline = heartbeats.length; + await clock.tickAsync(1); + assert.deepStrictEqual({ + beforeOldReply, beforeDeadline, heartbeats, errors, + latestCredential: requests.filter(request => request.path.endsWith('/heartbeat')).at(-1)?.credential, + active: service.isEnabled && !sockets.at(-1)?.closed, + }, { + beforeOldReply: { sockets: 2, closed: [true, false] }, + beforeDeadline: 3, + heartbeats: [{ time: 0, status: 'online' }, { time: 60_000, status: 'online' }, { time: 60_000, status: 'online' }, { time: 300_000, status: 'online' }], + errors: [`Mission Control request failed (${status})`], + latestCredential: 'Bearer refreshed-token', + active: true, + }); + }); + }); + } + + test('a successful stale check-in cannot replace or stop the refreshed relay', async () => { + await withEnvironment([], async ({ service, clock, options, sockets, delayHeartbeat, requests, errors }) => { + const delayed = delayHeartbeat(); + await clock.tickAsync(60_000); + await delayed.started; + await service.configure({ ...options, credential: 'refreshed-token' }); + await delayed.complete(); + await clock.tickAsync(60_000); + assert.deepStrictEqual({ + sockets: sockets.length, closed: sockets.map(socket => socket.closed), errors, + latestCredential: requests.filter(request => request.path.endsWith('/heartbeat')).at(-1)?.credential, + }, { + sockets: 2, closed: [true, false], errors: [], latestCredential: 'Bearer refreshed-token', + }); + }); + }); + + for (const openWorkspace of [false, true]) { + test(`adds trusted same-owner roots without rebinding the ${openWorkspace ? 'initial workspace' : 'empty-window'} default`, async () => { + await withEnvironment([], async ({ service, options, directory, attachments, sockets, requests }) => { + const additional = await mkdtemp(join(directory, 'additional-')); + await service.configure({ ...options, roots: [additional] }); + await service.configure({ ...options, roots: [additional] }); + const initial = openWorkspace ? [realpathSync(directory)] : []; + assert.deepStrictEqual({ + initialRoots: attachments[0].initialRoots, + currentRoots: attachments[0].getRoots(), + sockets: sockets.length, + registrations: requests.filter(request => request.path.endsWith('/register')).length, + }, { + initialRoots: initial, currentRoots: [...initial, realpathSync(additional)], sockets: 1, registrations: 1, + }); + }, undefined, openWorkspace); + }); + } + + test('withdrawal clears explicit grants and re-enables with a new registration default', async () => { + await withEnvironment([], async ({ service, options, directory, attachments, sockets }) => { + const workspaceA = realpathSync(directory); + const workspaceB = realpathSync(await mkdtemp(join(directory, 'workspace-b-'))); + await service.configure({ ...options, roots: [workspaceB] }); + const whileActive = { initialRoots: attachments[0].initialRoots, roots: attachments[0].getRoots() }; + await service.configure({ ...options, roots: [workspaceB], credential: 'refreshed-token' }); + const afterCredentialRefresh = { initialRoots: attachments[1].initialRoots, roots: attachments[1].getRoots() }; + const disabling = service.configure(undefined, options.accountId); + const withdrawnRoots = attachments[1].getRoots(); + await disabling; + await service.configure({ ...options, roots: [workspaceB], credential: 'refreshed-token' }); + assert.deepStrictEqual({ + whileActive, afterCredentialRefresh, withdrawnRoots, + reenabled: { initialRoots: attachments[2].initialRoots, roots: attachments[2].getRoots() }, + closed: sockets.map(socket => socket.closed), + }, { + whileActive: { initialRoots: [workspaceA], roots: [workspaceA, workspaceB] }, + afterCredentialRefresh: { initialRoots: [workspaceA], roots: [workspaceA, workspaceB] }, + withdrawnRoots: [], + reenabled: { initialRoots: [workspaceB], roots: [workspaceB] }, + closed: [true, true, false], + }); + }, undefined, true); + }); + + test('withdraws immediately during identity refresh and allows the pinned owner to reconfigure after signout', async () => { + await withEnvironment([], async ({ service, options, sockets, delayIdentity, requests }) => { + const delayed = delayIdentity(); + const refreshing = service.configure({ ...options, credential: 'refreshed-token' }); + await delayed.started; + const disabling = service.configure(undefined); + const withdrawn = { enabled: service.isEnabled, closed: sockets.every(socket => socket.closed) }; + await delayed.complete(Response.json({ id: 123, type: 'User' })); + await Promise.all([refreshing, disabling]); + const attachmentsBeforeReenable = sockets.length; + await service.configure(options); + assert.deepStrictEqual({ + withdrawn, attachmentsBeforeReenable, sockets: sockets.length, + enabled: service.isEnabled, + registrations: requests.filter(request => request.path.endsWith('/register')).length, + }, { + withdrawn: { enabled: false, closed: true }, attachmentsBeforeReenable: 1, sockets: 2, enabled: true, registrations: 2, + }); + }); + }); + + test('settings/consent disable closes ingress synchronously while the offline request is pending', async () => { + await withEnvironment([], async ({ service, options, sockets, delayHeartbeat, clock, heartbeats }) => { + const delayed = delayHeartbeat(); + const disabling = service.configure(undefined); + const withdrawn = { enabled: service.isEnabled, closed: sockets.every(socket => socket.closed) }; + await delayed.started; + await delayed.complete(new Response('{}', { headers: { 'Retry-After': '300' } })); + await disabling; + await service.configure(options); + await clock.tickAsync(299_999); + const beforeDeadline = heartbeats.length; + await clock.tickAsync(1); + assert.deepStrictEqual({ withdrawn, beforeDeadline, heartbeats }, { + withdrawn: { enabled: false, closed: true }, + beforeDeadline: 2, + heartbeats: [{ time: 0, status: 'online' }, { time: 0, status: 'offline' }, { time: 300_000, status: 'online' }], + }); + }); + }); + + test('a rejected foreign account cannot withdraw the pinned owner or close its relay', async () => { + await withEnvironment([{ retryAfter: '300' }], async ({ service, options, sockets, clock, heartbeats, requests, errors }) => { + await assert.rejects(service.configure({ ...options, accountId: 'other' }), /another local account/); + const requestCount = requests.length; + await assert.rejects(service.configure(undefined, 'other'), /Only the Agent Host owner/); + const immediatelyAfterRefusal = { + enabled: service.isEnabled, environmentId: service.environmentId, + closed: sockets[0].closed, extraRequests: requests.length - requestCount, + }; + await clock.tickAsync(299_999); + const beforeDeadline = heartbeats.length; + await clock.tickAsync(1); + assert.deepStrictEqual({ immediatelyAfterRefusal, beforeDeadline, heartbeats, sockets: sockets.length, errors }, { + immediatelyAfterRefusal: { enabled: true, environmentId: 'environment', closed: false, extraRequests: 0 }, + beforeDeadline: 1, + heartbeats: [{ time: 0, status: 'online' }, { time: 300_000, status: 'online' }], + sockets: 1, errors: [], + }); + }); + }); + + test('account-bound owner withdrawal is immediate and preserves Retry-After across re-enable', async () => { + await withEnvironment([{ retryAfter: '300' }], async ({ service, options, sockets, clock, heartbeats, errors }) => { + const disabling = service.configure(undefined, options.accountId); + const withdrawn = { enabled: service.isEnabled, closed: sockets[0].closed }; + await disabling; + await service.configure(options, 'other'); + await clock.tickAsync(299_999); + const beforeDeadline = heartbeats.length; + await clock.tickAsync(1); + assert.deepStrictEqual({ withdrawn, beforeDeadline, heartbeats, sockets: sockets.length, errors }, { + withdrawn: { enabled: false, closed: true }, + beforeDeadline: 1, + heartbeats: [{ time: 0, status: 'online' }, { time: 300_000, status: 'online' }], + sockets: 2, errors: [], + }); + }); + }); + + test('rejects owner and live scope conflicts without replacing the first grant', async () => { + await withEnvironment([], async ({ service, options, sockets, attachments, directory }) => { + for (const conflicting of [ + { ...options, accountId: 'other', roots: [directory] }, + { ...options, baseUrl: 'https://other.test', roots: [directory] }, + { ...options, requireConnectionBinding: true, roots: [directory] }, + ]) { + await assert.rejects(service.configure(conflicting), /another local account|already configured/); + } + assert.deepStrictEqual({ enabled: service.isEnabled, sockets: sockets.length, closed: sockets[0].closed, roots: attachments[0].getRoots() }, { + enabled: true, sockets: 1, closed: false, roots: [], + }); + await service.configure(undefined); + await assert.rejects(service.configure({ ...options, accountId: 'other' }), /another local account/); + }); + }); + + test('reports changed and removed managed policy immediately without parsing it or losing heartbeat Retry-After', async () => { + await withEnvironment([{ retryAfter: '300' }], async ({ changePolicy, clock, requests, heartbeats, sockets, errors }) => { + await changePolicy({ mode: 'disabled' }); + await clock.tickAsync(1000); + await changePolicy(undefined); + await clock.tickAsync(298_999); + const beforeDeadline = heartbeats.length; + await clock.tickAsync(1); + assert.deepStrictEqual({ + policies: requests.filter(request => request.path.endsWith('/register')).map(request => request.body?.managed_settings), + beforeDeadline, heartbeats, sockets: sockets.length, errors, + }, { + policies: [undefined, { remoteControl: { mode: 'disabled' } }, undefined], + beforeDeadline: 1, + heartbeats: [{ time: 0, status: 'online' }, { time: 300_000, status: 'online' }], + sockets: 1, errors: [], + }); + }); + }); + }); + + function createIdentityService(userData: string, computeIds: string[], names?: string[]): MissionControlEnvironment { + const { key } = signingFixture(); + return store.add(new MissionControlEnvironment({ + userDataPath: userData, + name: 'VS Code OSS', + fetch: async (input, init) => { + const url = new URL(input.toString()); + if (url.pathname.endsWith('/register')) { + const body = JSON.parse(String(init?.body)) as { compute_id: string; name: string }; + computeIds.push(body.compute_id); + names?.push(body.name); + } + return Response.json(url.pathname.endsWith('/jwks.json') ? { keys: [key] } : { + id: 'environment', user_id: 'owner', owner_id: 'owner', owner_type: 'user', kind: 'user-local', + webpubsub: { url: 'ws://127.0.0.1/fake', access_token: 'fake-token', subprotocol: 'json.reliable.webpubsub.azure.v1', groups: { control: `${prefix}.control` } }, + }); + }, + attach: () => ({ dispose() { } }), + onError: error => { throw error; }, + socketFactory: () => { + const socket = new FakeWpsSocket(); + queueMicrotask(() => socket.emit('message', JSON.stringify({ type: 'system', event: 'connected' }))); + return socket; + } + } + )); + } + + test('reuses compute identity across windows and restarts, but not different user-data directories', async () => { + const path = await mkdtemp(join(process.cwd(), '.build', 'mission-control-identity-')); + try { + const computeIds: string[] = []; + const names: string[] = []; + const options = { baseUrl: 'http://127.0.0.1:9999/', accountId: 'owner', credential: 'fake-token', roots: [path] }; + const createService = (userData: string) => createIdentityService(userData, computeIds, names); + const first = createService(join(path, 'normal')); + await Promise.all([first.configure(options), first.configure(options)]); + const registrationsForTwoWindows = computeIds.length; + await first.configure(undefined); + first.dispose(); + const restarted = createService(join(path, 'normal')); + await restarted.configure(options); + const separateProfile = createService(join(path, 'isolated')); + await separateProfile.configure(options); + assert.deepStrictEqual({ + registrationsForTwoWindows, + restartIdentityMatches: computeIds[0] === computeIds[1], + isolatedIdentityDiffers: computeIds[0] !== computeIds[2], + registrations: computeIds.length, + names, + }, { + registrationsForTwoWindows: 1, restartIdentityMatches: true, isolatedIdentityDiffers: true, registrations: 3, + names: ['VS Code OSS', 'VS Code OSS', 'VS Code OSS'], + }); + } finally { + await rm(path, { recursive: true }); + } + }); + + test('copied identity records rotate only the copy and preserve both identities across restart', async () => { + const path = await mkdtemp(join(process.cwd(), '.build', 'mission-control-copy-')); + try { + const originalDirectory = join(path, 'original'); + const copiedDirectory = join(path, 'copy'); + const filename = 'agent-host-mission-control-id'; + const computeIds: string[] = []; + const options = { baseUrl: 'http://127.0.0.1:9999/', accountId: 'owner', credential: 'fake-token', roots: [path] }; + const original = createIdentityService(originalDirectory, computeIds); + await original.configure(options); + const originalRecord = await readFile(join(originalDirectory, filename), 'utf8'); + await mkdir(copiedDirectory); + await copyFile(join(originalDirectory, filename), join(copiedDirectory, filename)); + const copied = createIdentityService(copiedDirectory, computeIds); + await copied.configure(options); + const copiedRecord: object = JSON.parse(await readFile(join(copiedDirectory, filename), 'utf8')); + original.dispose(); + copied.dispose(); + await createIdentityService(originalDirectory, computeIds).configure(options); + await createIdentityService(copiedDirectory, computeIds).configure(options); + assert.deepStrictEqual({ + originalUnchanged: await readFile(join(originalDirectory, filename), 'utf8') === originalRecord, + copyIsDistinct: computeIds[0] !== computeIds[1], + stableOriginal: computeIds[0] === computeIds[2], + stableCopy: computeIds[1] === computeIds[3], + copiedRecord, + }, { + originalUnchanged: true, copyIsDistinct: true, stableOriginal: true, stableCopy: true, + copiedRecord: { version: 1, id: computeIds[1], userDataDirectory: realpathSync(copiedDirectory) }, + }); + } finally { + await rm(path, { recursive: true }); + } + }); + + test('migrates a legacy compute UUID in place without changing its identity', async () => { + const path = await mkdtemp(join(process.cwd(), '.build', 'mission-control-legacy-')); + try { + const filename = join(path, 'agent-host-mission-control-id'); + const legacyId = randomUUID(); + await writeFile(filename, legacyId, { mode: 0o600, flag: 'wx' }); + const computeIds: string[] = []; + const options = { baseUrl: 'http://127.0.0.1:9999/', accountId: 'owner', credential: 'fake-token', roots: [path] }; + const first = createIdentityService(path, computeIds); + await first.configure(options); + first.dispose(); + await createIdentityService(path, computeIds).configure(options); + assert.deepStrictEqual({ + computeIds, + record: JSON.parse(await readFile(filename, 'utf8')), + }, { + computeIds: [legacyId, legacyId], + record: { version: 1, id: legacyId, userDataDirectory: realpathSync(path) }, + }); + } finally { + await rm(path, { recursive: true }); + } + }); + + test('canonical directory aliases reuse the same compute identity', async () => { + const path = await mkdtemp(join(process.cwd(), '.build', 'mission-control-canonical-')); + try { + const originalDirectory = join(path, 'original'); + const alias = join(path, 'alias'); + const computeIds: string[] = []; + const options = { baseUrl: 'http://127.0.0.1:9999/', accountId: 'owner', credential: 'fake-token', roots: [path] }; + await createIdentityService(originalDirectory, computeIds).configure(options); + await symlink(originalDirectory, alias, 'junction'); + await createIdentityService(alias, computeIds).configure(options); + assert.deepStrictEqual({ + sameIdentity: computeIds[0] === computeIds[1], + record: JSON.parse(await readFile(join(alias, 'agent-host-mission-control-id'), 'utf8')), + }, { + sameIdentity: true, + record: { version: 1, id: computeIds[0], userDataDirectory: realpathSync(originalDirectory) }, + }); + } finally { + await rm(path, { recursive: true }); + } + }); + + test('registration metadata is bounded and late answers cannot register a disabled host', async () => { + const path = await mkdtemp(join(process.cwd(), '.build', 'mission-control-metadata-timeout-')); + const clock = sinon.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }); + try { + const metadata = new DeferredPromise(); + const started = new DeferredPromise(); + const requests: string[] = []; + const service = store.add(new MissionControlEnvironment({ + userDataPath: path, + name: 'VS Code OSS', + fetch: async input => { + requests.push(input.toString()); + return Response.json({ id: 123, type: 'User' }); + }, + attach: () => { throw new Error('Timed-out registration must not attach a server'); }, + onError: error => { throw error; }, + getSessionCount: () => { started.complete(); return metadata.p; }, + getRemoteControlPolicy: async () => undefined + } + )); + const configuring = service.configure({ baseUrl: 'https://api.github.com', live: true, accountId: '123', credential: 'fake-token', roots: [] }); + const rejected = assert.rejects(configuring, /registration metadata timed out/); + await started.p; + await clock.tickAsync(15_000); + await rejected; + await metadata.complete(42); + await clock.tickAsync(60_000); + assert.deepStrictEqual(requests, ['https://api.github.com/user']); + service.dispose(); + } finally { + clock.restore(); + await rm(path, { recursive: true }); + } + }); + + test('live registration does not require a local workspace or create a client connection', async () => { + const path = await mkdtemp(join(process.cwd(), '.build', 'mission-control-host-only-')); + try { + const { key } = signingFixture(); + const requestPaths: string[] = []; + let attachedRoots: readonly string[] | undefined; + const service = store.add(new MissionControlEnvironment({ + userDataPath: path, + name: 'VS Code OSS', + fetch: async input => { + const url = new URL(input.toString()); + requestPaths.push(url.pathname); + return Response.json(url.pathname === '/user' ? { id: 123, type: 'User' } : url.pathname.endsWith('/jwks.json') ? { keys: [key] } : { + id: 'environment', user_id: '123', owner_id: '123', owner_type: 'user', kind: 'user-local', + webpubsub: { url: 'wss://wps.test/client/hubs/test', access_token: 'fake-token', subprotocol: 'json.reliable.webpubsub.azure.v1', groups: { control: 'user.123.env.environment.control' } }, + }); + }, + attach: (_server, roots) => { attachedRoots = roots; return { dispose() { } }; }, + onError: error => { throw error; }, + socketFactory: () => { + const socket = new FakeWpsSocket(); + queueMicrotask(() => socket.emit('message', JSON.stringify({ type: 'system', event: 'connected' }))); + return socket; + }, + getRemoteControlPolicy: async () => undefined + } + )); + await service.configure({ baseUrl: 'https://api.github.com', live: true, accountId: '123', credential: 'fake-token', roots: [] }); + assert.deepStrictEqual({ requestPaths, attachedRoots }, { + requestPaths: ['/user', '/cmc_internal/api/agents/environments/register', '/cmc_internal/api/agents/environments/.well-known/jwks.json', '/cmc_internal/api/agents/environments/environment/heartbeat'], + attachedRoots: [], + }); + } finally { + await rm(path, { recursive: true }); + } + }); +}); diff --git a/src/vs/platform/agentHost/test/node/missionControlSdkEventSource.test.ts b/src/vs/platform/agentHost/test/node/missionControlSdkEventSource.test.ts new file mode 100644 index 00000000000000..fad829b475cdb6 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/missionControlSdkEventSource.test.ts @@ -0,0 +1,221 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import sinon from 'sinon'; +import { DeferredPromise } from '../../../../base/common/async.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { URI } from '../../../../base/common/uri.js'; +import { mock } from '../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../instantiation/test/common/instantiationServiceMock.js'; +import { ILogService, NullLogService } from '../../../log/common/log.js'; +import type { IAgent, IAgentChatSessionEvent } from '../../common/agent.js'; +import type { CancellationToken } from '../../../../base/common/cancellation.js'; +import { ISessionDataService, type ISessionDatabase } from '../../common/sessionDataService.js'; +import { SessionStatus, buildDefaultChatUri } from '../../common/state/sessionState.js'; +import { IAgentHostProviderService } from '../../node/agentHostProviderService.js'; +import { AgentHostStateManager, IAgentHostStateManager } from '../../node/agentHostStateManager.js'; +import { MissionControlSdkEventSource } from '../../node/missionControl/missionControlSdkEventSource.js'; +import { MissionControlSessionMirror, type MissionControlMirrorEvent } from '../../node/missionControl/missionControlSessionMirror.js'; + +const session = 'ahp-session:/native'; +const chat = URI.parse(buildDefaultChatUri(session)); +const at = '2026-10-02T21:00:00Z'; + +function event(id: string, type = 'session.idle', data: object = {}): IAgentChatSessionEvent { + return { chat, id, timestamp: at, persisted: true, type, data }; +} + +suite('MissionControlSdkEventSource', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + let clock: sinon.SinonFakeTimers; + setup(() => { clock = sinon.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); }); + teardown(() => sinon.restore()); + + function fixture(history: readonly IAgentChatSessionEvent[] = [], metadata = new Map(), initiallyEnabled = true) { + const instantiation = store.add(new TestInstantiationService()); + const log = store.add(new NullLogService()); + const errors = sinon.spy(log, 'error'); + instantiation.stub(ILogService, log); + const state = store.add(new AgentHostStateManager(log)); + state.restoreSession({ resource: session, provider: 'copilotcli', title: 'Native title', status: SessionStatus.Idle, createdAt: at, modifiedAt: at }, []); + instantiation.stub(IAgentHostStateManager, state); + const emitter = store.add(new Emitter()); + let historyRead = 0; + const provider = new class extends mock() { + override readonly id = 'copilotcli'; + override readonly onDidChatSessionEvent = emitter.event; + override async *readChatSessionEvents() { + historyRead++; + yield* history; + } + override async synchronizeChatSessionTitle(_chat: URI, title: string) { + emitter.fire({ ...event('native-title', 'session.title_changed', { title }), persisted: false }); + return true; + } + }(); + const providers = new class extends mock() { + override readonly onDidRegisterProvider = Event.None; + override getProviders() { return [provider]; } + override getProviderForSession() { return provider; } + }(); + instantiation.stub(IAgentHostProviderService, providers); + const writes: string[] = []; + const db = new class extends mock() { + override async getMetadata(key: string) { return metadata.get(key); } + override async setMetadata(key: string, value: string) { + if (key.includes('sdkSequence')) { writes.push(value); } + metadata.set(key, value); + } + }(); + instantiation.stub(ISessionDataService, new class extends mock() { + override openDatabase() { return { object: db, dispose: () => { } }; } + }()); + const events: MissionControlMirrorEvent[] = []; + const mirror = store.add(instantiation.createInstance(MissionControlSessionMirror, 'env', {})); + store.add(mirror.attach(value => { events.push(value); })); + let enabled = initiallyEnabled; + const source = store.add(instantiation.createInstance(MissionControlSdkEventSource, 'env', mirror, () => enabled)); + return { source, mirror, emitter, provider, events, errors, writes, metadata, setEnabled: (value: boolean) => { enabled = value; }, historyRead: () => historyRead }; + } + + test('reconciles genuine metadata history, deduplicates live overlap and synchronizes the native title', async () => { + const idle = event('idle'); + const f = fixture([event('old-title', 'session.title_changed', { title: 'Old title' }), idle]); + f.emitter.fire(idle); + await f.source.whenIdle(); + clock.runAll(); + assert.deepStrictEqual({ + events: f.events.flatMap(value => value.event === 'sessionEvents' && value.data.ns === 'sdk' ? [value.data.payload] : []), + writes: f.writes, + historyReads: f.historyRead(), + }, { + events: [ + { type: 'session.title_changed', data: { title: 'Old title' } }, + { type: 'session.idle', data: {} }, + { type: 'session.title_changed', data: { title: 'Native title' } }, + ], + writes: ['1024'], historyReads: 1, + }); + }); + + test('durably reserves non-colliding SDK sequence ranges across process restart', async () => { + const first = fixture([event('idle')]); + await first.source.whenIdle(); + clock.runAll(); + first.mirror.ingestAck({ watermarks: { [session]: { sdk: 1 } } }); + await first.source.whenIdle(); + first.source.dispose(); + first.mirror.dispose(); + const second = fixture([event('idle')], first.metadata); + await second.source.whenIdle(); + clock.runAll(); + assert.deepStrictEqual({ + writes: second.writes, + sequences: second.events.flatMap(value => value.event === 'sessionEvents' ? [value.data.seq] : []), + }, { writes: ['2048'], sequences: [1024] }); + }); + + test('disabled mirroring performs no provider reads, title writes or sequence reservations', async () => { + const f = fixture([], new Map(), false); + await f.source.whenIdle(); + f.emitter.fire(event('ignored')); + clock.runAll(); + assert.deepStrictEqual({ events: f.events, reads: f.historyRead(), writes: f.writes }, { events: [], reads: 0, writes: [] }); + }); + + test('peer-chat events cannot overwrite the owning task metadata', async () => { + const f = fixture(); + await f.source.whenIdle(); + clock.runAll(); + f.events.length = 0; + f.emitter.fire({ ...event('peer-title', 'session.title_changed', { title: 'Wrong title' }), chat: URI.parse('ahp-chat://peer/YWhwLXNlc3Npb246L25hdGl2ZQ') }); + await f.source.whenIdle(); + clock.runAll(); + assert.deepStrictEqual(f.events, []); + }); + + test('a failed durable write prevents publication and reports the failure', async () => { + const f = fixture([event('idle')]); + sinon.stub(f.metadata, 'set').throws(new Error('disk failure')); + await f.source.whenIdle(); + clock.runAll(); + assert.deepStrictEqual({ events: f.events, failed: f.errors.called }, { events: [], failed: true }); + }); + + test('disposal fences a pending metadata read and prevents late publication', async () => { + const f = fixture(); + const gate = new DeferredPromise(); + sinon.stub(f.provider, 'readChatSessionEvents').callsFake(async function* () { + await gate.p; + yield event('late'); + }); + await Promise.resolve(); + f.source.dispose(); + gate.complete(); + await f.source.whenIdle(); + clock.runAll(); + assert.deepStrictEqual(f.events, []); + }); + + test('a title RPC failure does not stop subsequent metadata or poison the local session', async () => { + const f = fixture([event('idle')]); + sinon.stub(f.provider, 'synchronizeChatSessionTitle').onFirstCall().rejects(new Error('name RPC unavailable')).onSecondCall().resolves(false); + await f.source.whenIdle(); + f.emitter.fire(event('next-turn', 'assistant.turn_start', { turnId: 'next' })); + await f.source.whenIdle(); + clock.runAll(); + assert.deepStrictEqual({ + types: f.events.flatMap(value => value.event === 'sessionEvents' && value.data.ns === 'sdk' ? [value.data.payload.type] : []), + logged: f.errors.called, + }, { types: ['session.idle', 'assistant.turn_start'], logged: true }); + }); + + test('an unreadable journal is reported without preventing fresh native metadata', async () => { + const f = fixture(); + sinon.stub(f.provider, 'readChatSessionEvents').callsFake(async function* () { throw new Error('Corrupt journal'); }); + f.emitter.fire(event('fresh-idle')); + await f.source.whenIdle(); + clock.runAll(); + assert.deepStrictEqual({ + types: f.events.flatMap(value => value.event === 'sessionEvents' && value.data.ns === 'sdk' ? [value.data.payload.type] : []), + logged: f.errors.called, + }, { types: ['session.idle', 'session.title_changed'], logged: true }); + }); + + test('restart replays unacknowledged native metadata instead of advancing the durable cursor at admission', async () => { + const first = fixture([event('idle')]); + await first.source.whenIdle(); + clock.runAll(); + assert.strictEqual(first.metadata.has('missionControl.sdkJournalCursor.env'), false); + first.source.dispose(); + first.mirror.dispose(); + const second = fixture([event('idle')], first.metadata); + await second.source.whenIdle(); + clock.runAll(); + assert.deepStrictEqual(second.events.flatMap(value => value.event === 'sessionEvents' && value.data.ns === 'sdk' ? [value.data.payload.type] : []), + ['session.idle', 'session.title_changed']); + }); + + test('initial reconciliation discards queued events already superseded by the journal snapshot', async () => { + const f = fixture(); + const start = event('overlapping-start', 'assistant.turn_start', { turnId: 'turn' }); + const end = event('reconciled-end', 'assistant.turn_end', { turnId: 'turn' }); + sinon.stub(f.provider, 'readChatSessionEvents').callsFake(async function* ( + _chat?: URI, _context?: object, _token?: CancellationToken, _cursor?: string, onRead?: (id: string) => void, + ) { + onRead?.(start.id); + onRead?.(end.id); + yield end; + }); + f.emitter.fire(start); + f.emitter.fire(end); + await f.source.whenIdle(); + clock.runAll(); + assert.deepStrictEqual(f.events.flatMap(value => value.event === 'sessionEvents' && value.data.ns === 'sdk' ? [value.data.payload.type] : []), + ['assistant.turn_end', 'session.title_changed']); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/missionControlSessionMirror.test.ts b/src/vs/platform/agentHost/test/node/missionControlSessionMirror.test.ts new file mode 100644 index 00000000000000..e9ce58cc5ce39a --- /dev/null +++ b/src/vs/platform/agentHost/test/node/missionControlSessionMirror.test.ts @@ -0,0 +1,624 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../instantiation/test/common/instantiationServiceMock.js'; +import { ILogService, NullLogService } from '../../../log/common/log.js'; +import { ActionType, type ActionEnvelope, type ChatDeltaAction } from '../../common/state/sessionActions.js'; +import { Reassembler } from '../../common/webPubSub/chunking.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { + MissionControlSessionMirror, type IMissionControlSessionMirrorOptions, type MissionControlMirrorEvent, type IMissionControlMirrorBackfill, +} from '../../node/missionControl/missionControlSessionMirror.js'; + +const environment = 'env_xyz'; +const sessionId = 'ahp-session:/sess_abc123'; +const otherSession = 'custom-session:/different-owner'; +const at = '2026-05-19T18:00:00.123Z'; + +function action(serverSeq = 102, content = 'part-102'): ActionEnvelope & { readonly action: ChatDeltaAction } { + return { + channel: 'ahp-chat:/opaque-chat', action: { type: ActionType.ChatDelta, turnId: 't1', partId: 'p1', content }, + serverSeq, origin: undefined, + }; +} + +function backfill(from_seq: number, to_seq: number, session_id = sessionId): IMissionControlMirrorBackfill { + return { kind: 'backfill_request', environment_id: environment, session_id, ns: 'ahp', from_seq, to_seq, request_id: 'bf-request' }; +} + +function frames(events: readonly MissionControlMirrorEvent[]) { + return events.flatMap(event => event.event === 'sessionEvents' && event.data.ns === 'ahp' ? [event.data] : []); +} + +function sdkFrames(events: readonly MissionControlMirrorEvent[]) { + return events.flatMap(event => event.event === 'sessionEvents' && event.data.ns === 'sdk' ? [event.data] : []); +} + +suite('MissionControlSessionMirror', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + let clock: sinon.SinonFakeTimers; + + setup(() => { + clock = sinon.useFakeTimers({ now: Date.parse(at), toFake: ['Date', 'setTimeout', 'clearTimeout'] }); + }); + + teardown(() => { + sinon.restore(); + }); + + function fixture(options: IMissionControlSessionMirrorOptions = {}) { + const instantiation = store.add(new TestInstantiationService()); + const log = store.add(new NullLogService()); + const errors = sinon.spy(log, 'error'); + const warnings = sinon.spy(log, 'warn'); + instantiation.stub(ILogService, log); + const mirror = store.add(instantiation.createInstance(MissionControlSessionMirror, environment, options)); + mirror.registerSession(sessionId); + const events: MissionControlMirrorEvent[] = []; + const attach = () => store.add(mirror.attach(event => { events.push(event); })); + return { mirror, events, attach, errors, warnings }; + } + + test('publishes the flat authoritative envelope in the sessionEvents user-event shape, never inline', () => { + const { mirror, events, attach } = fixture(); + attach(); + const envelope = { ...action(), origin: { clientId: 'client', clientSeq: 9 }, rejectionReason: 'rejected' }; + const expected = JSON.parse(JSON.stringify(envelope)); + assert.strictEqual(mirror.enqueue(envelope, sessionId), true); + envelope.action.content = 'mutated after dispatch'; + assert.deepStrictEqual(events, []); + clock.runAll(); + assert.deepStrictEqual(events, [{ + type: 'event', event: 'sessionEvents', dataType: 'json', + data: { environment_id: environment, session_id: sessionId, ns: 'ahp', seq: 0, at, payload: { kind: 'message', data: expected } }, + }]); + }); + + test('sequences each chunk separately and replays exact retained frames in ordered backfill', () => { + const { mirror, events, attach } = fixture({ chunkOptions: { maxChunkBytes: 180, newGroupId: () => 'fixed-group' } }); + attach(); + const envelope = action(412, 'large payload '.repeat(50)); + mirror.enqueue(envelope, sessionId); + clock.runAll(); + const original = frames(events); + assert.ok(original.length > 3); + const reassembler = new Reassembler(); + let reassembled: unknown = null; + for (const [index, frame] of original.entries()) { + assert.strictEqual(frame.seq, index); + assert.strictEqual(frame.payload.kind, 'chunk'); + reassembled = reassembler.ingest(frame.payload); + } + assert.deepStrictEqual(reassembled, JSON.parse(JSON.stringify(envelope))); + events.length = 0; + clock.tick(1000); + mirror.backfill(backfill(1, 3)); + assert.deepStrictEqual(events, []); + clock.runAll(); + assert.deepStrictEqual(frames(events), original.slice(1, 4)); + }); + + test('has exactly 1024 credits per session, not per channel or transport connection', () => { + const { mirror, events, attach } = fixture(); + mirror.registerSession(otherSession); + attach(); + for (let index = 0; index < 1025; index++) { + mirror.enqueue(action(index), sessionId); + } + mirror.enqueue(action(9000), otherSession); + clock.runAll(); + assert.deepStrictEqual({ + main: frames(events).filter(frame => frame.session_id === sessionId).length, + other: frames(events).filter(frame => frame.session_id === otherSession).map(frame => frame.seq), + published: mirror.getSessionStatus(sessionId).publishedSeq, + }, { main: 1024, other: [0], published: 1023 }); + mirror.ingestAck({ watermarks: { [sessionId]: { ahp: 0 } } }); + clock.runAll(); + assert.deepStrictEqual(frames(events).filter(frame => frame.session_id === sessionId).map(frame => frame.seq), + Array.from({ length: 1025 }, (_, index) => index)); + }); + + test('ingest acknowledgements validate atomically and only free published contiguous prefixes', () => { + const { mirror, attach } = fixture(); + mirror.registerSession(otherSession); + mirror.enqueue(action(), sessionId); + assert.throws(() => mirror.ingestAck({ watermarks: { [sessionId]: { ahp: 0 } } }), /exceeds published/); + attach(); + mirror.enqueue(action(200), sessionId); + mirror.enqueue(action(), otherSession); + clock.runAll(); + for (const invalid of [ + { watermarks: { [sessionId]: { ahp: -1 } } }, + { watermarks: { [sessionId]: { ahp: 0.5 } } }, + { watermarks: { [sessionId]: { ahp: Number.MAX_SAFE_INTEGER + 1 } } }, + { watermarks: { [sessionId]: { ahp: '0' } } }, + { watermarks: { [sessionId]: {} } }, + { watermarks: { [sessionId]: { raw: 0 } } }, + { watermarks: { [sessionId]: { ahp: 0 }, [otherSession]: { ahp: 1 } } }, + { watermarks: null }, + ]) { + assert.throws(() => mirror.ingestAck(invalid)); + } + assert.strictEqual(mirror.statistics.retainedFrames, 3); + mirror.ingestAck({ ack_for_batch: 'batch-id', watermarks: { [sessionId]: { sdk: 999 } } }); + assert.strictEqual(mirror.statistics.retainedFrames, 3); + mirror.ingestAck({ watermarks: { [sessionId]: { ahp: 0 }, 'unknown-session:/old': { ahp: 200 } } }); + const retainedBytes = mirror.statistics.retainedBytes; + mirror.ingestAck({ watermarks: { [sessionId]: { ahp: 0 } } }); + assert.deepStrictEqual({ frames: mirror.statistics.retainedFrames, bytes: mirror.statistics.retainedBytes }, { frames: 2, bytes: retainedBytes }); + mirror.ingestAck({ watermarks: { [sessionId]: { ahp: 1 }, [otherSession]: { ahp: 0 } } }); + mirror.ingestAck({ watermarks: { [sessionId]: { ahp: 0 } } }); + assert.deepStrictEqual(mirror.statistics, { sessions: 2, retainedFrames: 0, retainedBytes: 0, pendingBackfillFrames: 0, pendingBackfillRequests: 0 }); + }); + + test('disconnect retains sequences and spools; reconnect retransmits unacknowledged frames without new credit', () => { + const { mirror, events, attach } = fixture(); + const oldAttachment = attach(); + for (let index = 0; index < 1025; index++) { + mirror.enqueue(action(index), sessionId); + } + clock.runAll(); + mirror.ingestAck({ watermarks: { [sessionId]: { ahp: 0 } } }); + oldAttachment.dispose(); + events.length = 0; + attach(); + oldAttachment.dispose(); + clock.runAll(); + assert.deepStrictEqual(frames(events).map(frame => frame.seq), Array.from({ length: 1024 }, (_, index) => index + 1)); + assert.deepStrictEqual({ + published: mirror.getSessionStatus(sessionId).publishedSeq, next: mirror.getSessionStatus(sessionId).nextSeq, + retained: mirror.statistics.retainedFrames, + }, { published: 1024, next: 1025, retained: 1024 }); + }); + + test('disposing a replaced attachment cannot close the current sender', () => { + const { mirror, events, attach } = fixture(); + const stale = attach(); + attach(); + stale.dispose(); + mirror.enqueue(action(), sessionId); + clock.runAll(); + assert.deepStrictEqual(frames(events).map(frame => frame.seq), [0]); + }); + + test('transient publisher pressure pauses without consuming frames or spinning timers', () => { + const { mirror, events, warnings } = fixture(); + let blocked = true; + let attempts = 0; + store.add(mirror.attach(event => { + attempts++; + if (blocked) { + return false; + } + events.push(event); + return undefined; + })); + mirror.enqueue(action(), sessionId); + clock.runAll(); + mirror.enqueue(action(103), sessionId); + clock.tick(60_000); + const paused = { attempts, published: mirror.getSessionStatus(sessionId).publishedSeq, retained: mirror.statistics.retainedFrames, timers: clock.countTimers() }; + blocked = false; + mirror.resumePublishing(); + clock.runAll(); + assert.deepStrictEqual({ + paused, sequences: frames(events).map(frame => frame.seq), retained: mirror.statistics.retainedFrames, warnings: warnings.callCount, + }, { + paused: { attempts: 1, published: -1, retained: 2, timers: 0 }, sequences: [0, 1], retained: 2, warnings: 0, + }); + }); + + test('replacing a blocked transport replays retained frames without a stale attachment detaching it', () => { + const { mirror, events, attach, warnings } = fixture(); + const stale = store.add(mirror.attach(() => false)); + mirror.enqueue(action(), sessionId); + clock.runAll(); + attach(); + stale.dispose(); + mirror.resumePublishing(); + clock.runAll(); + assert.deepStrictEqual({ + sequences: frames(events).map(frame => frame.seq), retained: mirror.statistics.retainedFrames, warnings: warnings.callCount, + }, { sequences: [0], retained: 1, warnings: 0 }); + }); + + test('a throwing transport detaches without losing the failed frame or disrupting direct AHP', () => { + const { mirror, events, attach, warnings } = fixture(); + const direct: ActionEnvelope[] = []; + store.add(mirror.attach(event => { + if (event.event === 'sessionEvents' && event.data.seq === 1) { + throw new Error('queue full'); + } + events.push(event); + })); + for (let index = 0; index < 3; index++) { + const envelope = action(index); + direct.push(envelope); + assert.strictEqual(mirror.enqueue(envelope, sessionId), true); + } + clock.runAll(); + assert.deepStrictEqual({ + sent: frames(events).map(frame => frame.seq), direct: direct.length, warnings: warnings.callCount, + published: mirror.getSessionStatus(sessionId).publishedSeq, retained: mirror.statistics.retainedFrames, + failure: mirror.getSessionStatus(sessionId).failure, + }, { sent: [0], direct: 3, warnings: 1, published: 0, retained: 3, failure: undefined }); + assert.throws(() => mirror.ingestAck({ watermarks: { [sessionId]: { ahp: 1 } } }), /exceeds published/); + mirror.ingestAck({ watermarks: { [sessionId]: { ahp: 0 } } }); + events.length = 0; + attach(); + clock.runAll(); + assert.deepStrictEqual(frames(events).map(frame => frame.seq), [1, 2]); + }); + + test('failed backfill transport resumes with the same chunk groups and sequence positions', () => { + const { mirror, events, attach } = fixture({ chunkOptions: { maxChunkBytes: 200, newGroupId: () => 'retained-group' } }); + attach(); + mirror.enqueue(action(800, 'x'.repeat(1000)), sessionId); + clock.runAll(); + const original = JSON.parse(JSON.stringify(frames(events))); + store.add(mirror.attach(() => { throw new Error('offline'); })); + mirror.backfill(backfill(0, 2)); + clock.runAll(); + assert.strictEqual(mirror.statistics.pendingBackfillFrames, 3); + events.length = 0; + attach(); + clock.runAll(); + assert.deepStrictEqual(frames(events), [...original, ...original.slice(0, 3)]); + assert.strictEqual(mirror.statistics.pendingBackfillFrames, 0); + }); + + test('acknowledgements prune queued backfill without replaying freed frames', () => { + const { mirror, events, attach } = fixture(); + attach(); + for (let index = 0; index < 5; index++) { + mirror.enqueue(action(index), sessionId); + } + clock.runAll(); + const original = frames(events); + mirror.backfill(backfill(0, 4)); + mirror.ingestAck({ watermarks: { [sessionId]: { ahp: 2 } } }); + assert.strictEqual(mirror.statistics.pendingBackfillFrames, 2); + events.length = 0; + clock.runAll(); + assert.deepStrictEqual(frames(events), original.slice(3)); + assert.strictEqual(mirror.statistics.pendingBackfillRequests, 0); + assert.throws(() => mirror.backfill(backfill(0, 1)), /unavailable/); + }); + + test('an ack can retire every scheduled frame and backfill before the next flush', () => { + const { mirror, events, attach } = fixture(); + attach(); + mirror.enqueue(action(), sessionId); + clock.runAll(); + mirror.backfill(backfill(0, 0)); + mirror.ingestAck({ watermarks: { [sessionId]: { ahp: 0 } } }); + clock.runAll(); + assert.deepStrictEqual({ sent: frames(events).length, retained: mirror.statistics.retainedFrames, pending: mirror.statistics.pendingBackfillFrames }, + { sent: 1, retained: 0, pending: 0 }); + }); + + test('backfill rejects wrong environments, namespaces, unsent and unbounded ranges without changing the spool', () => { + const { mirror, attach } = fixture({ maxBackfillFrames: 3, maxBackfillRequests: 2 }); + attach(); + for (let index = 0; index < 5; index++) { + mirror.enqueue(action(index), sessionId); + } + clock.runAll(); + assert.throws(() => mirror.backfill({ + ...backfill(0, 1), + // @ts-expect-error The runtime ingress must also reject the non-backfillable namespace. + ns: 'sdk', + })); + for (const request of [ + { ...backfill(0, 1), environment_id: 'other' }, + backfill(0, 5), backfill(2, 1), backfill(-1, 1), backfill(0, Number.MAX_SAFE_INTEGER), + { ...backfill(0, 1), request_id: '' }, + ]) { + assert.throws(() => mirror.backfill(request)); + } + assert.throws(() => mirror.backfill(backfill(0, 3)), /capacity/); + mirror.backfill(backfill(0, 1)); + mirror.backfill(backfill(2, 2)); + assert.throws(() => mirror.backfill(backfill(3, 3)), /capacity/); + assert.strictEqual(mirror.statistics.pendingBackfillFrames, 3); + clock.runAll(); + assert.strictEqual(mirror.statistics.retainedFrames, 5); + }); + + test('backfill request entries are bounded separately from frame work and released after publishing', () => { + const { mirror, attach } = fixture({ maxBackfillFrames: 10, maxBackfillRequests: 1 }); + attach(); + mirror.enqueue(action(), sessionId); + clock.runAll(); + mirror.backfill(backfill(0, 0)); + assert.throws(() => mirror.backfill(backfill(0, 0)), /capacity/); + clock.runAll(); + mirror.backfill(backfill(0, 0)); + clock.runAll(); + assert.deepStrictEqual({ frames: mirror.statistics.pendingBackfillFrames, requests: mirror.statistics.pendingBackfillRequests }, { frames: 0, requests: 0 }); + }); + + test('spool overflow is atomic for a complete action and emits a sticky sessionLifecycle failure', () => { + const { mirror, events, attach, errors } = fixture({ maxSessionFrames: 1, chunkOptions: { maxChunkBytes: 180, newGroupId: () => 'group' } }); + assert.strictEqual(mirror.enqueue(action(400, 'small'), sessionId), true); + assert.strictEqual(mirror.enqueue(action(412, 'x'.repeat(1000)), sessionId), false); + mirror.setLifecycle(sessionId, 'failed'); + mirror.setLifecycle(sessionId, 'completed'); + assert.strictEqual(mirror.enqueue(action(500), sessionId), false); + assert.deepStrictEqual({ next: mirror.getSessionStatus(sessionId).nextSeq, errors: errors.callCount }, { next: 1, errors: 1 }); + attach(); + clock.runAll(); + const failure = { + type: 'event', event: 'sessionLifecycle', dataType: 'json', + data: { + environment_id: environment, session_id: sessionId, kind: 'mirror_failed', at, + details: { namespace: 'ahp', reason: 'spool_capacity', server_seq: 412 }, + }, + }; + assert.deepStrictEqual(events.filter(event => event.event === 'sessionLifecycle'), [ + failure, { type: 'event', event: 'sessionLifecycle', dataType: 'json', data: { environment_id: environment, session_id: sessionId, kind: 'completed', at } }, + ]); + events.length = 0; + attach(); + clock.runAll(); + assert.deepStrictEqual(events.filter(event => event.event === 'sessionLifecycle'), [failure]); + }); + + for (const limit of ['maxSessionBytes', 'maxTotalBytes', 'maxTotalFrames'] as const) { + test(`${limit} exhaustion fails visibly without affecting another session`, () => { + const { mirror, events, attach } = fixture({ [limit]: 1 }); + mirror.registerSession(otherSession); + if (limit === 'maxTotalFrames') { + assert.strictEqual(mirror.enqueue(action(), otherSession), true); + } + assert.strictEqual(mirror.enqueue(action(412), sessionId), false); + attach(); + clock.runAll(); + assert.deepStrictEqual(events.find(event => event.event === 'sessionLifecycle')?.data, { + environment_id: environment, session_id: sessionId, kind: 'mirror_failed', at, + details: { namespace: 'ahp', reason: 'spool_capacity', server_seq: 412 }, + }); + assert.strictEqual(mirror.getSessionStatus(otherSession).failure, undefined); + }); + } + + test('exact byte budgets admit a full frame, ack restores capacity, and overflow assigns no sequence', () => { + const baseline = fixture(); + baseline.attach(); + baseline.mirror.enqueue(action(), sessionId); + clock.runAll(); + const bytes = Buffer.byteLength(JSON.stringify(baseline.events[0])); + const { mirror, attach } = fixture({ maxSessionBytes: bytes, maxTotalBytes: bytes }); + attach(); + assert.strictEqual(mirror.enqueue(action(), sessionId), true); + assert.strictEqual(mirror.statistics.retainedBytes, bytes); + clock.runAll(); + mirror.ingestAck({ watermarks: { [sessionId]: { ahp: 0 } } }); + assert.strictEqual(mirror.statistics.retainedBytes, 0); + assert.strictEqual(mirror.enqueue(action(), sessionId), true); + assert.strictEqual(mirror.enqueue(action(), sessionId), false); + assert.deepStrictEqual({ + retained: mirror.statistics.retainedBytes, nextSeq: mirror.getSessionStatus(sessionId).nextSeq, + reason: mirror.getSessionStatus(sessionId).failure?.reason, + }, { retained: bytes, nextSeq: 2, reason: 'spool_capacity' }); + }); + + test('serialization, framing and source lag use the closed failure details, with first failure sticky', () => { + const { mirror, events, attach } = fixture({ chunkOptions: { newGroupId: () => '', maxChunkBytes: 180 } }); + mirror.registerSession(otherSession); + mirror.registerSession('custom-session:/lagged'); + const circular: Record = {}; + circular.self = circular; + mirror.enqueue({ ...action(412), action: { ...action().action, _meta: circular } }, sessionId); + mirror.enqueue(action(500, 'x'.repeat(1000)), otherSession); + mirror.reportSourceLag('custom-session:/lagged', 5); + mirror.reportSourceLag(sessionId, 10); + attach(); + clock.runAll(); + assert.deepStrictEqual(events.map(event => event.data), [ + { environment_id: environment, session_id: sessionId, kind: 'mirror_failed', at, details: { namespace: 'ahp', reason: 'serialization_failed', server_seq: 412 } }, + { environment_id: environment, session_id: otherSession, kind: 'mirror_failed', at, details: { namespace: 'ahp', reason: 'framing_failed', server_seq: 500 } }, + { environment_id: environment, session_id: 'custom-session:/lagged', kind: 'mirror_failed', at, details: { namespace: 'ahp', reason: 'source_lag', skipped: 5 } }, + ]); + }); + + test('bounds session entries without eviction, sequence reuse or implicit channel ownership', () => { + const { mirror, events, attach } = fixture({ maxSessions: 1 }); + mirror.registerSession(sessionId); + assert.throws(() => mirror.registerSession(otherSession), /session capacity/); + assert.throws(() => mirror.enqueue(action(), otherSession), /not registered/); + attach(); + mirror.enqueue(action(400), sessionId); + clock.runAll(); + mirror.ingestAck({ watermarks: { [sessionId]: { ahp: 0 } } }); + mirror.registerSession(sessionId); + mirror.enqueue({ ...action(900), channel: 'custom-changeset:/unrelated-name' }, sessionId); + clock.runAll(); + assert.deepStrictEqual(frames(events).map(frame => ({ session: frame.session_id, seq: frame.seq })), [ + { session: sessionId, seq: 0 }, { session: sessionId, seq: 1 }, + ]); + }); + + test('project metadata is repeated without retaining caller mutation and outer event bytes respect the ceiling', () => { + const { mirror, events, attach } = fixture({ maxEventBytes: 700, chunkOptions: { newGroupId: () => 'g' } }); + const project = { uri: 'https://github.com/octo/repo', display_name: 'octo/repo' }; + mirror.registerSession(otherSession, project); + project.display_name = 'mutated'; + attach(); + mirror.enqueue(action(412, '\u00e9'.repeat(1000)), otherSession); + clock.runAll(); + assert.ok(events.length > 1); + for (const event of events) { + assert.ok(Buffer.byteLength(JSON.stringify({ ...event, ackId: Number.MAX_SAFE_INTEGER })) <= 700); + } + assert.deepStrictEqual(frames(events).map(frame => frame.project), + events.map(() => ({ uri: 'https://github.com/octo/repo', display_name: 'octo/repo' }))); + assert.throws(() => mirror.registerSession(otherSession, project), /cannot change/); + }); + + test('a slow or saturated session cannot monopolise a bounded flush turn', () => { + const { mirror, events, attach } = fixture({ maxFramesPerFlush: 2 }); + mirror.registerSession(otherSession); + attach(); + for (let index = 0; index < 5; index++) { + mirror.enqueue(action(index), sessionId); + } + mirror.enqueue(action(100), otherSession); + clock.tick(1); + assert.deepStrictEqual(frames(events).map(frame => frame.session_id), [sessionId, otherSession]); + clock.runAll(); + assert.strictEqual(frames(events).length, 6); + }); + + test('registration reserves room for visible failure and rejects invalid resource limits', () => { + const { mirror } = fixture({ maxEventBytes: 400 }); + assert.throws(() => mirror.registerSession(`custom-session:/${'x'.repeat(400)}`), /integrity signal/); + assert.throws(() => mirror.registerSession('not-a-session-uri'), /metadata/); + const instantiation = store.add(new TestInstantiationService()); + instantiation.stub(ILogService, store.add(new NullLogService())); + for (const options of [{ maxSessions: 0 }, { maxTotalBytes: Infinity }, { maxEventBytes: 1024 * 1024 + 1 }]) { + assert.throws(() => instantiation.createInstance(MissionControlSessionMirror, environment, options), /limits/); + } + }); + + test('sender mutation cannot alter backfill and disposing cancels all scheduled work', () => { + const { mirror, events } = fixture(); + store.add(mirror.attach(event => { + if (event.event === 'sessionEvents' && event.data.ns === 'ahp' && event.data.payload.kind === 'message') { + const envelope = event.data.payload.data as ActionEnvelope; + if (envelope.action.type === ActionType.ChatDelta) { + envelope.action.content = 'sender mutation'; + } + } + })); + mirror.enqueue(action(), sessionId); + clock.runAll(); + store.add(mirror.attach(event => { events.push(event); })); + mirror.backfill(backfill(0, 0)); + clock.runAll(); + assert.deepStrictEqual(frames(events).map(frame => frame.payload), [ + { kind: 'message', data: JSON.parse(JSON.stringify(action())) }, + { kind: 'message', data: JSON.parse(JSON.stringify(action())) }, + ]); + mirror.enqueue(action(500), sessionId); + mirror.dispose(); + clock.runAll(); + assert.strictEqual(events.length, 2); + assert.strictEqual(mirror.statistics.retainedBytes, 0); + assert.throws(() => mirror.enqueue(action(), sessionId), /disposed/); + }); + + test('SDK metadata has independent sequences and credits and does not block AHP', () => { + const { mirror, events, attach } = fixture(); + mirror.reserveSdkSequences(sessionId, 2048, 4096); + attach(); + for (let index = 0; index < 1025; index++) { + mirror.enqueueSdk(sessionId, { type: 'session.title_changed', data: { title: `Title ${index}` } }, at); + } + mirror.enqueue(action(), sessionId); + clock.runAll(); + assert.deepStrictEqual({ + ahp: frames(events).map(frame => frame.seq), + sdk: sdkFrames(events).map(frame => frame.seq), + }, { ahp: [0], sdk: Array.from({ length: 1024 }, (_, index) => 2048 + index) }); + assert.throws(() => mirror.ingestAck({ watermarks: { [sessionId]: { ahp: 0, sdk: 4000 } } }), /exceeds published/); + assert.strictEqual(mirror.statistics.retainedFrames, 1); + mirror.ingestAck({ watermarks: { [sessionId]: { sdk: 2048 } } }); + clock.runAll(); + assert.strictEqual(sdkFrames(events).at(-1)?.seq, 3072); + assert.strictEqual(mirror.statistics.retainedFrames, 1); + }); + + test('SDK overload coalesces a bounded truncation marker without mutating already-published frames', () => { + const { mirror, events, attach } = fixture({ maxSessionFrames: 1 }); + mirror.reserveSdkSequences(sessionId, 0, 1024); + mirror.enqueueSdk(sessionId, { type: 'session.title_changed', data: { title: 'Retained' } }, at); + mirror.enqueueSdk(sessionId, { type: 'session.idle', data: {} }, at); + mirror.enqueueSdk(sessionId, { type: 'session.idle', data: {} }, at); + attach(); + clock.runAll(); + mirror.enqueueSdk(sessionId, { type: 'session.idle', data: {} }, at); + const before = sdkFrames(events).map(frame => frame.payload); + mirror.ingestAck({ watermarks: { [sessionId]: { sdk: 1 } } }); + clock.runAll(); + assert.deepStrictEqual(sdkFrames(events).map(frame => ({ seq: frame.seq, payload: frame.payload })), [ + { seq: 0, payload: { type: 'session.title_changed', data: { title: 'Retained' } } }, + { seq: 1, payload: { type: 'session.events_truncated', data: { dropped_count: 2 } } }, + { seq: 2, payload: { type: 'session.events_truncated', data: { dropped_count: 1 } } }, + ]); + assert.deepStrictEqual(sdkFrames(events).slice(0, 2).map(frame => frame.payload), before); + }); + + test('oversized SDK bodies become a portable placeholder rather than chunks or authoritative mirror failure', () => { + const { mirror, events, attach } = fixture({ maxEventBytes: 500 }); + mirror.reserveSdkSequences(sessionId, 0, 1024); + const payload = { type: 'session.title_changed', data: { title: 'x'.repeat(1000) } }; + mirror.enqueueSdk(sessionId, payload, at); + attach(); + clock.runAll(); + const frame = sdkFrames(events)[0]; + const expectedBytes = Buffer.byteLength(JSON.stringify({ + type: 'event', event: 'sessionEvents', dataType: 'json', + data: { environment_id: environment, session_id: sessionId, ns: 'sdk', seq: 0, at, payload }, + })); + assert.deepStrictEqual(frame.payload, { + type: 'session.title_changed', data: null, _truncated: { reason: 'oversize', bytes: expectedBytes }, + }); + assert.strictEqual(mirror.getSessionStatus(sessionId).failure, undefined); + assert.ok(Buffer.byteLength(JSON.stringify(events[0])) < 500); + }); + + test('SDK truncation-marker retransmission preserves losses accumulated after its first publication', () => { + const { mirror, events, attach } = fixture({ maxSessionFrames: 1 }); + mirror.reserveSdkSequences(sessionId, 0, 1024); + mirror.enqueueSdk(sessionId, { type: 'session.idle', data: {} }, at); + mirror.enqueueSdk(sessionId, { type: 'session.idle', data: {} }, at); + attach().dispose(); + attach(); + clock.runAll(); + mirror.enqueueSdk(sessionId, { type: 'session.idle', data: {} }, at); + attach(); + clock.runAll(); + mirror.ingestAck({ watermarks: { [sessionId]: { sdk: 1 } } }); + clock.runAll(); + assert.deepStrictEqual(sdkFrames(events).map(frame => frame.payload), [ + { type: 'session.idle', data: {} }, + { type: 'session.events_truncated', data: { dropped_count: 1 } }, + { type: 'session.idle', data: {} }, + { type: 'session.events_truncated', data: { dropped_count: 1 } }, + { type: 'session.events_truncated', data: { dropped_count: 1 } }, + ]); + }); + + test('journal replay waits for SDK capacity and retains the newest state instead of dropping it', async () => { + const { mirror, events, attach } = fixture({ maxSessionFrames: 1 }); + mirror.reserveSdkSequences(sessionId, 0, 1024); + mirror.enqueueSdk(sessionId, { type: 'assistant.turn_start', data: { turnId: 'old' } }, at); + attach(); + const latest = { type: 'assistant.turn_end', data: { turnId: 'old' } }; + let ready = false; + const capacity = mirror.waitForSdkCapacity(sessionId, latest, at, CancellationToken.None).then(() => { ready = true; }); + clock.runAll(); + assert.strictEqual(ready, false); + mirror.ingestAck({ watermarks: { [sessionId]: { sdk: 0 } } }); + await capacity; + mirror.enqueueSdk(sessionId, latest, at); + clock.runAll(); + assert.deepStrictEqual(sdkFrames(events).map(frame => frame.payload.type), ['assistant.turn_start', 'assistant.turn_end']); + }); + + test('SDK replay capacity waits cancel without leaking ownership', async () => { + const { mirror } = fixture({ maxSessionFrames: 1 }); + mirror.reserveSdkSequences(sessionId, 0, 1024); + mirror.enqueueSdk(sessionId, { type: 'session.idle', data: {} }, at); + const cancellation = store.add(new CancellationTokenSource()); + const capacity = mirror.waitForSdkCapacity(sessionId, { type: 'session.idle', data: {} }, at, cancellation.token); + cancellation.cancel(); + await assert.rejects(capacity, /Canceled/); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 7c9b5c463db482..5df8fba4675966 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -4,21 +4,29 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { generateKeyPairSync, sign, type JsonWebKey } from 'crypto'; +import { EventEmitter } from 'events'; +import sodium from 'libsodium-wrappers'; +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'fs/promises'; +import { dirname, join } from '../../../../base/common/path.js'; import { NullAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; import { supportsAgentHostTiming } from '../../common/meta/agentHostTimingMeta.js'; import { supportsAgentHostSessionImport } from '../../common/meta/agentHostSessionImportMeta.js'; import { readChatInputState, withChatInputState } from '../../common/meta/agentHostChatInputState.js'; import { readSessionInitiator, withSessionInitiator } from '../../common/meta/agentSessionInitiatorMeta.js'; +import { readCodexSessionModel, withCodexSessionModel } from '../../common/meta/codexSessionModel.js'; import { type IAgentHostFirstResponseDiagnostic } from '../../common/otel/agentHostTiming.js'; -import { DeferredPromise } from '../../../../base/common/async.js'; +import { DeferredPromise, timeout } from '../../../../base/common/async.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { hasKey } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; +import { VSBuffer } from '../../../../base/common/buffer.js'; import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; import { FileType } from '../../../files/common/files.js'; +import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; import { type IAgentCreateChatRequestOptions, type IAgentCreateSessionConfig, type IAgentResolveSessionConfigParams, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata, type AuthenticateParams, type AuthenticateResult } from '../../common/agent.js'; @@ -29,12 +37,14 @@ import type { AutomationCapabilities, Implementation } from '../../common/state/ import type { FetchAutomationRunsParams, FetchAutomationRunsResult, ListAutomationTriggerDefinitionsParams, ListAutomationTriggerDefinitionsResult, RunAutomationParams, RunAutomationResult } from '../../common/state/protocol/channels-automation/commands.js'; import { ActionType, type ActionEnvelope, type ChatAction, type ClientAnnotationsAction, type ClientAutomationAction, type ClientAutomationRunAction, type ClientChangesetAction, type IRootConfigChangedAction, type ProgressParams, type SessionAction, type TerminalAction } from '../../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../../common/state/protocol/version/registry.js'; -import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, JsonRpcErrorCodes, ProtocolError, AhpErrorCodes, AHP_UNSUPPORTED_PROTOCOL_VERSION, AHP_SESSION_NOT_FOUND, type AhpNotification, type InitializeResult, type ProtocolMessage, type ReconnectResult, type ResourceListResult, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot, type SubscribeResult } from '../../common/state/sessionProtocol.js'; -import { AUTOMATION_CATALOG_URI, ChatInteractivity, ChatOriginKind, MessageKind, ResponsePartKind, SessionStatus, ChangesetStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildChatUri, buildDefaultChatUri, readSessionExternal, readSessionWorkspaceless, withSessionExternal, withSessionWorkspaceless, type ChangesetState, type ChatState, type SessionState, type SessionSummary } from '../../common/state/sessionState.js'; +import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, JsonRpcErrorCodes, ProtocolError, AhpErrorCodes, AHP_UNSUPPORTED_PROTOCOL_VERSION, AHP_SESSION_NOT_FOUND, AHP_AUTH_REQUIRED, type AhpNotification, type InitializeResult, type ProtocolMessage, type JsonRpcResponse, type ReconnectResult, type ResourceListResult, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot, type SubscribeResult } from '../../common/state/sessionProtocol.js'; +import { ROOT_STATE_URI, AUTOMATION_CATALOG_URI, ChatInteractivity, ChatOriginKind, MessageKind, ResponsePartKind, SessionStatus, ChangesetStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, readSessionExternal, readSessionWorkspaceless, withSessionExternal, withSessionWorkspaceless, type ChangesetState, type ChatState, type SessionState, type SessionSummary } from '../../common/state/sessionState.js'; import { SessionInputRequestKind, TerminalClaimKind } from '../../common/state/protocol/state.js'; import type { SessionAddedParams, SessionSummaryChangedParams } from '../../common/state/protocol/notifications.js'; import type { IProtocolServer, IProtocolTransport } from '../../common/state/sessionTransport.js'; import { ProtocolServerHandler } from '../../node/protocolServerHandler.js'; +import { MissionControlEnvironment } from '../../node/missionControl/missionControlEnvironment.js'; +import type { IMissionControlSocket } from '../../node/missionControl/missionControlProtocolServer.js'; import { CompositeProtocolServer } from '../../node/compositeProtocolServer.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { AgentHostFileSystemProvider, agentHostUri, type IRemoteFilesystemConnection } from '../../common/agentHostFileSystemProvider.js'; @@ -47,13 +57,20 @@ import { AgentHostManagedSettingsService } from '../../node/agentHostManagedSett import { AgentHostTelemetryService } from '../../node/agentHostTelemetryService.js'; import { buildAnnotationsUri } from '../../common/annotationsUri.js'; import { buildSessionChangesetUri, buildTurnChangesetUri } from '../../common/changesetUri.js'; +import { buildSessionDbUri } from '../../common/sessionDbUri.js'; +import { buildPendingEditContentUri } from '../../common/pendingEditContentUri.js'; +import { buildGitBlobUri } from '../../node/gitDiffContent.js'; import { MockDevContainerService } from '../common/mockDevContainerService.js'; import { McpAuthRequiredReason } from '../../common/state/protocol/channels-session/state.js'; +import { AgentHostSessionUrisCapabilityMetaKey, supportsAgentHostSessionUris } from '../../common/meta/agentHostSessionUrisMeta.js'; // ---- Mock helpers ----------------------------------------------------------- class MockProtocolTransport implements IProtocolTransport { - constructor(readonly transportKind = AgentHostTransportKind.Unknown) { } + constructor(readonly transportKind = AgentHostTransportKind.Unknown, readonly clientId?: string, readonly passive?: boolean) { } + get relayClientId(): string | undefined { return this.clientId; } + get relayPassive(): boolean | undefined { return this.passive; } + relayAuthenticated: boolean | undefined; isDisposed = false; private readonly _onMessage = new Emitter(); @@ -177,6 +194,7 @@ class MockAgentService implements IAgentService { shutdownCalls = 0; createSessionBarrier: DeferredPromise | undefined; subscribeBarrier: DeferredPromise | undefined; + dispatchBarrier: DeferredPromise | undefined; readonly subscribeBarriers = new Map>(); readonly subscribeCalls: { resource: string; clientId: string }[] = []; readonly unsubscribeCalls: { resource: string; clientId: string }[] = []; @@ -190,6 +208,9 @@ class MockAgentService implements IAgentService { readonly onDidNotification = this._onDidNotification.event; private readonly _onMcpNotification = new Emitter(); readonly onMcpNotification = this._onMcpNotification.event; + emitMcpNotification(notification: import('../../common/agent.js').IMcpNotification): void { + this._onMcpNotification.fire(notification); + } private _stateManager!: AgentHostStateManager; @@ -198,7 +219,12 @@ class MockAgentService implements IAgentService { this._stateManager = sm; } - dispatchAction(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction | ClientAutomationAction | ClientAutomationRunAction, clientId: string, clientSeq: number, clientContext?: IAgentHostClientTelemetryContext): void { + dispatchAction(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction | ClientAutomationAction | ClientAutomationRunAction, clientId: string, clientSeq: number, clientContext?: IAgentHostClientTelemetryContext): void | Promise { + if (this.dispatchBarrier) { + const barrier = this.dispatchBarrier; + this.dispatchBarrier = undefined; + return barrier.p.then(() => this.dispatchAction(channel, action, clientId, clientSeq, clientContext)); + } this.handledActions.push(action); this.handledClientTypes.push(clientContext?.clientType); this.handledClientContexts.push(clientContext); @@ -365,7 +391,7 @@ function request(id: number, method: string, params?: unknown): ProtocolMessage } function findNotifications(sent: ProtocolMessage[], method: string): AhpNotification[] { - return sent.filter(isJsonRpcNotification) as AhpNotification[]; + return sent.filter(message => isJsonRpcNotification(message) && message.method === method) as AhpNotification[]; } function findResponse(sent: ProtocolMessage[], id: number): ProtocolMessage | undefined { @@ -492,6 +518,8 @@ suite('ProtocolServerHandler', () => { protocolVersion: PROTOCOL_VERSION, serverSeq: stateManager.serverSeq, meta: { + 'vscode.agentHost': true, + 'vscode.ahpSessionUris': true, 'vscode.detachedWorktrees': true, 'vscode.autonomousAutomations': true, 'vscode.getAgentHostSessionStateFile.chat': true, @@ -1602,6 +1630,324 @@ suite('ProtocolServerHandler', () => { }); }); + test('local-test relay binds clients and grants only active clients sessions in permitted roots', async () => { + const relay = disposables.add(new MockProtocolServer()); + disposables.add(new ProtocolServerHandler( + agentService, stateManager, relay, + { allowExtensionMethods: false, relayRoots: [process.cwd()] }, + disposables.add(new AgentHostFileSystemProvider()), logService, NullTelemetryService, + managedSettingsService, clientConnections, devContainerService, NullAgentHostOTelService, + )); + const root = URI.file(process.cwd()).toString(); + const connect = (lane: string, claimed: string, passive: boolean) => { + const transport = new MockProtocolTransport(AgentHostTransportKind.WebSocket, lane, passive); + relay.simulateConnection(transport); + transport.simulateMessage(request(1, 'initialize', { protocolVersions: [PROTOCOL_VERSION], clientId: claimed })); + return transport; + }; + const mismatch = connect('lane-a', 'lane-b', false); + const passive = connect('passive', 'passive', true); + const active = connect('active', 'active', false); + const create = { channel: 'copilot:///relay-session', provider: 'copilot', workingDirectories: [root] }; + const passiveResult = waitForResponse(passive, 2); + passive.simulateMessage(request(2, 'createSession', create)); + const outsideResult = waitForResponse(active, 2); + active.simulateMessage(request(2, 'createSession', { ...create, workingDirectories: [URI.file('/not-granted').toString()] })); + const created = waitForResponse(active, 3); + active.simulateMessage(request(3, 'createSession', create)); + const listed = waitForResponse(active, 4); + active.simulateMessage(request(4, 'listSessions', {})); + await handler.whenIdle(); + const errorCode = (message: ProtocolMessage | undefined) => message && hasKey(message, { error: true }) ? message.error?.code : undefined; + const result = (message: ProtocolMessage) => hasKey(message, { result: true }) ? message.result : undefined; + assert.deepStrictEqual({ + mismatch: errorCode(findResponse(mismatch.sent, 1)), + passive: errorCode(await passiveResult), + outside: errorCode(await outsideResult), + created: result(await created), + listed: result(await listed), + configs: agentService.createSessionConfigs.length, + }, { + mismatch: JsonRpcErrorCodes.InvalidParams, + passive: JsonRpcErrorCodes.InvalidRequest, + outside: JsonRpcErrorCodes.InvalidParams, + created: null, + listed: { items: [] }, + configs: 1, + }); + }); + + test('live-contract Mission Control carries subscriptions, streaming, tools, and approvals through the native handler', async () => { + const local = connectClient('local-session-creator'); + local.simulateMessage(request(2, 'createSession', { channel: 'copilot:///local-session', provider: 'copilot', workingDirectories: [URI.file(process.cwd()).toString()] })); + await handler.whenIdle(); + agentService.listedSessions.push({ session: URI.parse('copilot:/local-session'), startTime: 1000, modifiedTime: 1000, workingDirectories: [URI.file(process.cwd())] }); + const userData = await mkdtemp(join(process.cwd(), '.build', 'mission-control-integration-')); + const events = new EventEmitter(); + const approvalDelivered = new DeferredPromise(); + const dispatchResponseDelivered = new DeferredPromise(); + const publishes: { group: string; data: { kind: string; generation?: number; data: { id?: number; result?: unknown; error?: unknown } } }[] = []; + const closures: (number | undefined)[] = []; + const groupPrefix = 'user.123.env.environment'; + const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }); + const key = { ...(publicKey.export({ format: 'jwk' }) as JsonWebKey), kid: 'fake-key', use: 'sig', alg: 'ES256' }; + const socket: IMissionControlSocket = { + on: (event, listener) => { events.on(event, listener); }, + close: () => { events.emit('close'); }, + send: data => { + const frame = JSON.parse(data) as { type: string; ackId?: number; group?: string; data?: { kind: string; generation?: number; data: { id?: number; result?: unknown; error?: unknown } } }; + if (frame.type === 'sendToGroup' && frame.data?.kind === 'closed') { + closures.push(frame.data.generation); + } else if (frame.type === 'sendToGroup' && frame.group && frame.data) { + publishes.push({ group: frame.group, data: frame.data }); + const message = frame.data.data as ProtocolMessage; + if (isJsonRpcNotification(message) && message.method === 'action' && (message.params as ActionEnvelope).action.type === ActionType.ChatToolCallConfirmed) { + approvalDelivered.complete(); + } + if (isJsonRpcResponse(message) && message.id === 8) { + dispatchResponseDelivered.complete(); + } + } + if (frame.ackId !== undefined) { + queueMicrotask(() => events.emit('message', JSON.stringify({ type: 'ack', ackId: frame.ackId, success: true }))); + } + }, + }; + const environmentResponse = { + id: 'environment', user_id: '123', owner_id: '123', owner_type: 'user', kind: 'user-local', + webpubsub: { url: 'wss://wps.test/client/hubs/test', access_token: 'fake-wps-token', subprotocol: 'json.reliable.webpubsub.azure.v1', groups: { control: `${groupPrefix}.control` } }, + }; + const fakeFetch: typeof fetch = async input => Response.json( + input.toString().endsWith('/user') ? { id: 123, type: 'User' } : input.toString().endsWith('jwks.json') ? { keys: [key] } : environmentResponse, + ); + let relayHandler: ProtocolServerHandler | undefined; + const registration = disposables.add(new MissionControlEnvironment({ + userDataPath: userData, + name: 'VS Code OSS', + fetch: fakeFetch, + attach: (relay, roots) => { + relayHandler = new ProtocolServerHandler( + agentService, stateManager, relay, + { allowExtensionMethods: false, relayRootMeta: relay.rootMeta, defaultDirectory: URI.file(roots[0]).toString() }, + disposables.add(new AgentHostFileSystemProvider()), logService, NullTelemetryService, + managedSettingsService, clientConnections, devContainerService, NullAgentHostOTelService, + ); + return relayHandler; + }, + onError: error => { throw error; }, + socketFactory: () => { + queueMicrotask(() => events.emit('message', JSON.stringify({ type: 'system', event: 'connected' }))); + return socket; + }, + getRemoteControlPolicy: async () => undefined + } + )); + try { + await registration.configure({ baseUrl: 'https://api.github.com', live: true, accountId: 'owner', credential: 'fake-local-token', roots: [process.cwd()] }); + const payload = { kind: 'spawn_request', client_id: 'client-a', spawn_request_id: 'spawn-a', passive: false }; + const header = Buffer.from(JSON.stringify({ alg: 'ES256', typ: 'JWT', kid: key.kid })).toString('base64url'); + const claims = Buffer.from(JSON.stringify({ + iat: Math.floor(Date.now() / 1000), jti: 'nonce-a', environment_id: 'environment', user_id: '123', kind: 'spawn_request', payload, + })).toString('base64url'); + const signingInput = `${header}.${claims}`; + const signature = sign('sha256', Buffer.from(signingInput), { key: privateKey, dsaEncoding: 'ieee-p1363' }); + const order = BigInt('0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551'); + const s = BigInt(`0x${signature.subarray(32).toString('hex')}`); + if (s > order / 2n) { + Buffer.from((order - s).toString(16).padStart(64, '0'), 'hex').copy(signature, 32); + } + const deliver = (group: string, value: object, sequenceId: number) => + events.emit('message', JSON.stringify({ type: 'message', from: 'group', fromUserId: '123', group, dataType: 'json', sequenceId, data: group === `${groupPrefix}.control` ? value : { kind: 'message', data: value } })); + deliver(`${groupPrefix}.control`, { ...payload, signature: `${signingInput}.${signature.toString('base64url')}` }, 1); + await Promise.resolve(); + const toHost = `${groupPrefix}.client.client-a.to-host`; + deliver(toHost, request(1, 'initialize', { clientId: 'client-a', protocolVersions: [PROTOCOL_VERSION], initialSubscriptions: [ROOT_STATE_URI] }), 2); + await relayHandler?.whenIdle(); + const initialized = publishes.find(publish => publish.data.data.id === 1)?.data.data.result as InitializeResult; + const rootSnapshot = initialized.snapshots?.find(snapshot => snapshot.resource === ROOT_STATE_URI); + const encryptionKeys = (rootSnapshot?.state as { _meta?: Record })._meta?.['copilot.encryptionKeys'] as { keyId: string; use: string; algorithm: string; publicKey: string }[]; + const encryptionKey = encryptionKeys.find(key => key.use === 'auth-token' && key.algorithm === 'x25519-sealedbox'); + assert.ok(encryptionKey); + await sodium.ready; + const box = sodium.crypto_box_seal(JSON.stringify({ cty: 'text', ctx: { purpose: 'auth-token', resource: 'https://api.github.com' }, value: 'fake-local-token' }), Buffer.from(encryptionKey.publicKey, 'base64')); + deliver(toHost, request(5, 'authenticate', { channel: ROOT_STATE_URI, resource: 'https://api.github.com', token: `copilot-sealed.v1.${encryptionKey.keyId}.${Buffer.from(box).toString('base64url')}` }), 3); + await relayHandler?.whenIdle(); + deliver(toHost, request(2, 'listSessions', {}), 4); + deliver(toHost, request(3, 'createSession', { channel: 'copilot:///fake-wps-session', provider: 'copilot', workingDirectories: [URI.file(process.cwd()).toString()] }), 5); + await relayHandler?.whenIdle(); + agentService.listedSessions.push({ + session: URI.parse('copilot:///fake-wps-session'), + startTime: 1000, + modifiedTime: 2000, + workingDirectories: [URI.file(process.cwd())], + project: { uri: URI.file(process.cwd()), displayName: 'Project' }, + }); + deliver(toHost, request(4, 'listSessions', {}), 6); + await relayHandler?.whenIdle(); + const nativeSession = 'copilot:/fake-wps-session'; + const nativeChat = buildDefaultChatUri(nativeSession); + local.simulateMessage(request(3, 'subscribe', { channel: nativeChat })); + local.simulateMessage(request(4, 'listSessions', {})); + await handler.whenIdle(); + const localList = findResponse(local.sent, 4); + assert.ok(localList && hasKey(localList, { result: true })); + deliver(toHost, request(6, 'subscribe', { channel: nativeChat }), 7); + await relayHandler?.whenIdle(); + deliver(toHost, request(7, 'dispatchAction', { + channel: nativeChat, clientSeq: 1, action: { + type: ActionType.ChatTurnStarted, turnId: 'relay-turn', startedAt: '2026-09-30T19:00:00.000Z', message: { text: 'test', origin: { kind: MessageKind.User } }, + } + }), 8); + stateManager.dispatchServerAction(nativeChat, { type: ActionType.ChatResponsePart, turnId: 'relay-turn', part: { kind: ResponsePartKind.Markdown, id: 'relay-part', content: '' } }); + stateManager.dispatchServerAction(nativeChat, { type: ActionType.ChatDelta, turnId: 'relay-turn', partId: 'relay-part', content: 'streamed response' }); + stateManager.dispatchServerAction(nativeChat, { type: ActionType.ChatToolCallStart, turnId: 'relay-turn', toolCallId: 'relay-tool', toolName: 'test', displayName: 'Test Tool' }); + stateManager.dispatchServerAction(nativeChat, { type: ActionType.ChatToolCallReady, turnId: 'relay-turn', toolCallId: 'relay-tool', invocationMessage: 'Approve Test Tool' }); + deliver(toHost, request(8, 'dispatchAction', { + channel: nativeChat, clientSeq: 2, action: { + type: ActionType.ChatToolCallConfirmed, turnId: 'relay-turn', toolCallId: 'relay-tool', approved: true, confirmed: ToolCallConfirmationReason.UserAction, + } + }), 9); + await Promise.all([approvalDelivered.p, dispatchResponseDelivered.p]); + const messages = publishes.map(publish => publish.data.data as ProtocolMessage); + const handshake = messages.find(message => isJsonRpcResponse(message) && message.id === 1) as { result: InitializeResult }; + assert.deepStrictEqual({ + results: publishes.map(publish => publish.data.data.id).filter(id => id !== undefined).sort((a, b) => a - b), + created: agentService.createSessionConfigs.length, + listed: (publishes.find(publish => publish.data.data.id === 4)?.data.data.result as ListSessionsResult | undefined)?.items.map(item => item.resource), + actions: messages.filter(isJsonRpcNotification).filter(message => message.method === 'action').map(message => (message.params as ActionEnvelope).action.type).filter(type => type.startsWith('chat/')), + handled: agentService.handledActions.map(action => action.type), + requiredEncryption: handshake.result._meta?.['copilot.encryptionRequired'], + advertisedArtifactRemoval: supportsAgentHostArtifactRemoval(handshake.result), + advertisedTiming: supportsAgentHostTiming(handshake.result), + standardSessionUris: supportsAgentHostSessionUris(handshake.result), + sealedPlaintextPublished: JSON.stringify(publishes).includes('fake-local-token'), + locallyListed: (localList.result as ListSessionsResult).items.map(item => item.resource), + localReceivedRemoteToolCall: local.sent.some(message => isJsonRpcNotification(message) && message.method === 'action' + && (message.params as ActionEnvelope).channel === nativeChat && (message.params as ActionEnvelope).action.type === ActionType.ChatToolCallReady), + }, { + results: [1, 2, 3, 4, 5, 6, 7, 8], + created: 2, + listed: ['copilot:/local-session', 'copilot:/fake-wps-session'], + actions: [ActionType.ChatTurnStarted, ActionType.ChatResponsePart, ActionType.ChatDelta, ActionType.ChatToolCallStart, ActionType.ChatToolCallReady, ActionType.ChatToolCallConfirmed], + handled: [ActionType.ChatTurnStarted, ActionType.ChatToolCallConfirmed], + requiredEncryption: ['auth-token', 'mcp-auth-token'], + advertisedArtifactRemoval: false, + advertisedTiming: false, + standardSessionUris: true, + sealedPlaintextPublished: false, + locallyListed: ['copilot:/local-session', 'copilot:/fake-wps-session'], + localReceivedRemoteToolCall: true, + }); + deliver(toHost, request(9, 'reconnect', { clientId: 'client-a', lastSeenServerSeq: stateManager.serverSeq, subscriptions: [nativeChat] }), 10); + await relayHandler?.whenIdle(); + const reconnect = publishes.find(publish => publish.data.data.id === 9)?.data.data as JsonRpcResponse; + assert.strictEqual(hasKey(reconnect, { error: true }) ? reconnect.error.code : undefined, AhpErrorCodes.NotFound); + deliver(toHost, request(10, 'initialize', { clientId: 'client-a', protocolVersions: [PROTOCOL_VERSION], initialSubscriptions: [ROOT_STATE_URI, nativeChat] }), 11); + await relayHandler?.whenIdle(); + const fresh = publishes.find(publish => publish.data.data.id === 10)?.data.data as JsonRpcResponse; + assert.deepStrictEqual(hasKey(fresh, { result: true }) ? (fresh.result as InitializeResult).snapshots?.map(snapshot => snapshot.resource) : undefined, [ROOT_STATE_URI]); + const failedGeneration = publishes.find(publish => publish.data.data.id === 9)?.data.generation; + const initializedGeneration = publishes.find(publish => publish.data.data.id === 10)?.data.generation; + assert.strictEqual(closures.length, 1); + assert.notStrictEqual(closures[0], failedGeneration); + assert.strictEqual(failedGeneration, initializedGeneration); + deliver(toHost, request(11, 'authenticate', { channel: ROOT_STATE_URI, resource: 'https://api.github.com', token: `copilot-sealed.v1.${encryptionKey.keyId}.${Buffer.from(box).toString('base64url')}` }), 12); + await relayHandler?.whenIdle(); + deliver(toHost, request(12, 'subscribe', { channel: nativeChat }), 13); + await relayHandler?.whenIdle(); + const recovered = publishes.find(publish => publish.data.data.id === 12)?.data.data as JsonRpcResponse; + assert.strictEqual(hasKey(recovered, { result: true }) ? (recovered.result as SubscribeResult).snapshot?.resource : undefined, nativeChat); + } finally { + registration.dispose(); + await rm(userData, { recursive: true }); + } + }); + + test('an asynchronous initialization failure releases the transport for a fresh handshake', async () => { + const transport = disposables.add(new MockProtocolTransport()); + const barrier = agentService.subscribeBarrier = new DeferredPromise(); + const snapshot = stateManager.getSnapshot.bind(stateManager); + let fail = false; + stateManager.getSnapshot = resource => { + if (fail && resource === ROOT_STATE_URI) { + throw new Error('Snapshot failed'); + } + return snapshot(resource); + }; + server.simulateConnection(transport); + transport.simulateMessage(request(1, 'initialize', { + clientId: 'async-retry', protocolVersions: [PROTOCOL_VERSION], initialSubscriptions: [ROOT_STATE_URI, buildAnnotationsUri(sessionUri)], + })); + fail = true; + await barrier.complete(); + await handler.whenIdle(); + const failed = findResponse(transport.sent, 1); + assert.ok(failed && hasKey(failed, { error: true })); + fail = false; + transport.simulateMessage(request(2, 'initialize', { clientId: 'async-retry', protocolVersions: [PROTOCOL_VERSION], initialSubscriptions: [ROOT_STATE_URI] })); + await handler.whenIdle(); + const retried = findResponse(transport.sent, 2); + assert.ok(retried && hasKey(retried, { result: true })); + }); + + for (const handshake of ['initialize', 'reconnect'] as const) { + test(`failed asynchronous ${handshake} preserves the predecessor's residual grace cleanup`, () => runWithFakedTimers({ useFakeTimers: true }, async () => { + stateManager.createSession(makeSessionSummary()); + stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady }); + stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionActiveClientSet, activeClient: { clientId: 'grace-owner', tools: [{ name: 'test', description: 'Test' }] } }); + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatTurnStarted, turnId: 'grace-turn', startedAt: '2026-10-02T00:00:00.000Z', message: { text: 'Test', origin: { kind: MessageKind.User } }, + }); + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatToolCallStart, turnId: 'grace-turn', toolCallId: 'grace-tool', toolName: 'test', displayName: 'Test', + contributor: { kind: ToolCallContributorKind.Client, clientId: 'grace-owner' }, + }); + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatToolCallReady, turnId: 'grace-turn', toolCallId: 'grace-tool', invocationMessage: 'Test', confirmed: ToolCallConfirmationReason.NotNeeded, + }); + const removed: string[] = []; + const originalRemove = managedSettingsService.removeClient.bind(managedSettingsService); + managedSettingsService.removeClient = id => { removed.push(id); originalRemove(id); }; + const previous = connectClient('grace-owner', [sessionUri]); + previous.simulateClose(); + await timeout(10_000); + const barrier = agentService.subscribeBarrier = new DeferredPromise(); + const snapshot = stateManager.getSnapshot.bind(stateManager); + const sessions = stateManager.getSessionUris.bind(stateManager); + let fail = false; + stateManager.getSnapshot = uri => { + if (handshake === 'initialize' && fail && uri === ROOT_STATE_URI) { + fail = false; + throw new Error('Asynchronous snapshot failure'); + } + return snapshot(uri); + }; + stateManager.getSessionUris = () => { + if (handshake === 'reconnect' && fail) { + fail = false; + throw new Error('Asynchronous restoration failure'); + } + return sessions(); + }; + const current = disposables.add(new MockProtocolTransport()); + server.simulateConnection(current); + current.simulateMessage(handshake === 'initialize' + ? request(1, 'initialize', { clientId: 'grace-owner', protocolVersions: [PROTOCOL_VERSION], initialSubscriptions: [ROOT_STATE_URI, buildAnnotationsUri(sessionUri)] }) + : request(1, 'reconnect', { clientId: 'grace-owner', lastSeenServerSeq: 0, subscriptions: [ROOT_STATE_URI, sessionUri] })); + fail = true; + await barrier.complete(); + await handler.whenIdle(); + const failed = findResponse(current.sent, 1); + assert.ok(failed && hasKey(failed, { error: true })); + await timeout(19_999); + assert.strictEqual(removed.length, 0); + await timeout(1); + const part = stateManager.getSessionState(sessionUri)?.activeTurn?.responseParts[0]; + assert.strictEqual(part?.kind === ResponsePartKind.ToolCall && part.toolCall.status === ToolCallStatus.Completed ? part.toolCall.success : undefined, false); + assert.strictEqual(removed.length, 1); + })); + } + test('ping responds after initialize', async () => { const transport = connectClient('client-1'); transport.sent.length = 0; @@ -1628,7 +1974,7 @@ suite('ProtocolServerHandler', () => { assert.strictEqual(result.snapshot.resource.toString(), sessionUri.toString()); }); - test('disconnect cancels a pending subscribe request', async () => { + test('disconnect cancels a pending subscribe request without replying on the ended transport', async () => { stateManager.createSession(makeSessionSummary()); agentService.subscribeBarrier = new DeferredPromise(); const transport = connectClient('client-1'); @@ -1646,7 +1992,7 @@ suite('ProtocolServerHandler', () => { unsubscribes: agentService.unsubscribeCalls, errorCount: logService.errorCount, }, { - response: { jsonrpc: '2.0', id: 2, error: { code: JSON_RPC_INTERNAL_ERROR, message: `Subscription cancelled: ${sessionUri}` } }, + response: undefined, subscribes: [{ resource: sessionUri, clientId: 'client-1' }], unsubscribes: [{ resource: sessionUri, clientId: 'client-1' }], errorCount: 0, @@ -1890,6 +2236,486 @@ suite('ProtocolServerHandler', () => { assert.strictEqual(envelope.origin.clientSeq, 1); }); + test('request-form dispatch acknowledges the actual active-client action and shares notification handling', async () => { + stateManager.createSession(makeSessionSummary()); + const transport = connectClient('client-1', [sessionUri]); + transport.sent.length = 0; + const action = { type: ActionType.SessionActiveClientSet, activeClient: { clientId: 'client-1', tools: [] } }; + transport.simulateMessage(request(2, 'dispatchAction', { channel: sessionUri, clientSeq: 1, action })); + await handler.whenIdle(); + const envelope = findNotifications(transport.sent, 'action').at(-1)?.params as ActionEnvelope; + assert.deepStrictEqual({ + response: findResponse(transport.sent, 2), + activeClient: stateManager.getSessionState(sessionUri)?.activeClients[0], + origin: envelope.origin, + }, { + response: { jsonrpc: '2.0', id: 2, result: { serverSeq: envelope.serverSeq } }, + activeClient: action.activeClient, + origin: { clientId: 'client-1', clientSeq: 1 }, + }); + }); + + test('live relay requires its own identity before requests and notifications can act', async () => { + stateManager.createSession(makeSessionSummary()); + const transport = disposables.add(new MockProtocolTransport(AgentHostTransportKind.WebSocket, 'relay-client', false)); + transport.relayAuthenticated = false; + server.simulateConnection(transport); + transport.simulateMessage(request(1, 'initialize', { clientId: 'relay-client', protocolVersions: [PROTOCOL_VERSION] })); + for (const [index, method] of ['listSessions', 'createSession', 'resourceRead', 'resourceWrite', 'createTerminal', 'subscribe', 'unsubscribe', 'dispatchAction'].entries()) { + transport.simulateMessage(request(index + 2, method, { channel: sessionUri, clientSeq: 1, action: { type: ActionType.SessionTitleChanged, title: 'Must not change' } })); + } + transport.simulateMessage(notification('dispatchAction', { channel: sessionUri, clientSeq: 2, action: { type: ActionType.SessionTitleChanged, title: 'Must not change' } })); + await handler.whenIdle(); + const denied = transport.sent.filter(isJsonRpcResponse).filter(message => message.id !== 1).map(message => hasKey(message, { error: true }) ? message.error.code : undefined); + assert.deepStrictEqual({ denied, dispatched: agentService.handledActions, created: agentService.createSessionConfigs.length }, { + denied: Array(8).fill(AHP_AUTH_REQUIRED), dispatched: [], created: 0, + }); + transport.relayAuthenticated = true; + transport.simulateMessage(request(10, 'dispatchAction', { channel: sessionUri, clientSeq: 3, action: { type: ActionType.SessionTitleChanged, title: 'Authorized' } })); + await handler.whenIdle(); + assert.strictEqual(stateManager.getSessionState(sessionUri)?.title, 'Authorized'); + }); + + test('relay root-config and managed-permission changes cannot affect local host state', async () => { + const transport = disposables.add(new MockProtocolTransport(AgentHostTransportKind.WebSocket, 'relay-client', false)); + transport.relayAuthenticated = true; + server.simulateConnection(transport); + transport.simulateMessage(request(1, 'initialize', { clientId: 'relay-client', protocolVersions: [PROTOCOL_VERSION], initialSubscriptions: [ROOT_STATE_URI] })); + transport.sent.length = 0; + const action = { type: ActionType.RootConfigChanged, config: { githubEnterpriseUri: 'https://attacker.test', customizations: ['file:///malicious'] } }; + transport.simulateMessage(request(2, 'dispatchAction', { channel: ROOT_STATE_URI, clientSeq: 1, action })); + transport.simulateMessage(notification('dispatchAction', { channel: ROOT_STATE_URI, clientSeq: 2, action })); + transport.simulateMessage(notification('setClientManagedSettingsPermissions', { permissions: { allow: ['Shell'] } })); + await handler.whenIdle(); + assert.deepStrictEqual({ + rejections: findNotifications(transport.sent, 'action').map(message => (message.params as ActionEnvelope).rejectionReason), + dispatched: agentService.handledActions, + permissions: managedSettingsService.permissions, + }, { + rejections: ['Relay clients cannot change host configuration', 'Relay clients cannot change host configuration'], + dispatched: [], + permissions: {}, + }); + }); + + test('pre-authentication root snapshots omit host configuration and MCP notifications', async () => { + stateManager.dispatchServerAction(ROOT_STATE_URI, { type: ActionType.RootConfigChanged, config: { mcpServers: { private: { env: { SECRET: 'test-private-secret' } } } } }); + const unauthenticated = disposables.add(new MockProtocolTransport(AgentHostTransportKind.WebSocket, 'guest-lane', false)); + unauthenticated.relayAuthenticated = false; + server.simulateConnection(unauthenticated); + unauthenticated.simulateMessage(request(1, 'initialize', { clientId: 'guest-lane', protocolVersions: [PROTOCOL_VERSION], initialSubscriptions: [ROOT_STATE_URI] })); + const initial = findResponse(unauthenticated.sent, 1); + const authenticated = connectClient('authorized-local', [ROOT_STATE_URI]); + agentService.emitMcpNotification({ channel: 'mcp://session/private-server', method: 'notifications/test', params: { content: 'private content' } }); + unauthenticated.relayAuthenticated = true; + unauthenticated.simulateMessage(request(2, 'subscribe', { channel: ROOT_STATE_URI })); + await handler.whenIdle(); + const full = findResponse(unauthenticated.sent, 2); + stateManager.dispatchServerAction(ROOT_STATE_URI, { type: ActionType.RootConfigChanged, config: { mcpServers: { private: { env: { SECRET: 'test-updated-secret' } } } } }); + stateManager.rejectClientAction(ROOT_STATE_URI, { type: ActionType.RootConfigChanged, config: { private: 'local-rejected-secret' } }, { clientId: 'authorized-local', clientSeq: 1 }, 'Rejected local config'); + assert.deepStrictEqual({ + initialContainsSecret: JSON.stringify(initial).includes('test-private-secret'), + initialConfig: initial && hasKey(initial, { result: true }) ? (initial.result as InitializeResult).snapshots?.some(snapshot => hasKey(snapshot.state, { config: true })) : undefined, + guestMcp: findNotifications(unauthenticated.sent, 'notifications/test').length > 0, + authorizedMcp: findNotifications(authenticated.sent, 'notifications/test').length > 0, + authorizedRootContainsSecret: JSON.stringify(full).includes('test-private-secret'), + relayReceivesHostSecretUpdate: JSON.stringify(unauthenticated.sent).includes('test-updated-secret'), + localReceivesHostSecretUpdate: JSON.stringify(authenticated.sent).includes('test-updated-secret'), + relayReceivesOtherClientRejection: JSON.stringify(unauthenticated.sent).includes('local-rejected-secret'), + }, { + initialContainsSecret: false, initialConfig: false, guestMcp: false, authorizedMcp: true, authorizedRootContainsSecret: false, + relayReceivesHostSecretUpdate: false, localReceivesHostSecretUpdate: true, + relayReceivesOtherClientRejection: false, + }); + }); + + test('MC filesystem grants cover workspace reads and writes without exposing private host files', async () => { + const path = await mkdtemp(join(process.cwd(), '.build', 'mission-control-resource-grants-')); + try { + const workspace = join(path, 'workspace'); + const content = join(path, 'content'); + const outside = join(path, 'outside'); + await Promise.all([mkdir(workspace), mkdir(content), mkdir(outside)]); + await writeFile(join(outside, 'private.txt'), 'private'); + await writeFile(join(workspace, 'not-directory.txt'), 'not a directory'); + await symlink(outside, join(workspace, 'escape'), process.platform === 'win32' ? 'junction' : 'dir'); + await symlink(join(outside, 'missing'), join(workspace, 'dangling'), process.platform === 'win32' ? 'junction' : 'file'); + stateManager.createSession({ ...makeSessionSummary(), workingDirectories: [URI.file(workspace).toString()] }); + const relay = disposables.add(new MockProtocolServer()); + const scopedHandler = disposables.add(new ProtocolServerHandler( + agentService, stateManager, relay, + { allowExtensionMethods: false, relayResourceRoots: readOnly => readOnly ? [workspace, content] : [workspace] }, + disposables.add(new AgentHostFileSystemProvider()), logService, NullTelemetryService, + managedSettingsService, clientConnections, devContainerService, NullAgentHostOTelService, + )); + const transport = disposables.add(new MockProtocolTransport(AgentHostTransportKind.WebSocket, 'scoped-lane', false)); + transport.relayAuthenticated = true; + relay.simulateConnection(transport); + transport.simulateMessage(request(1, 'initialize', { clientId: 'scoped-lane', protocolVersions: [PROTOCOL_VERSION] })); + const reads: string[] = []; + const writes: string[] = []; + const terminalDirectories: (string | undefined)[] = []; + const previews = disposables.add(new InMemoryFileSystemProvider()); + agentService.resourceRead = async uri => { + reads.push(uri.toString()); + return { data: uri.scheme === 'pending-edit-content' ? VSBuffer.wrap(await previews.readFile(uri)).toString() : 'allowed', encoding: ContentEncoding.Utf8 }; + }; + agentService.resourceWrite = async params => { + writes.push(params.uri); + return {}; + }; + agentService.createTerminal = async params => { + terminalDirectories.push(params.cwd); + }; + const dbContent = buildSessionDbUri(sessionUri, 'tool-call', join(workspace, 'file.txt'), 'before'); + const gitContent = buildGitBlobUri(sessionUri, 'a'.repeat(40), 'file.txt', join(workspace, 'file.txt')); + const gitHeadContent = buildGitBlobUri(sessionUri, 'HEAD', 'file.txt', join(workspace, 'file.txt')); + const pendingContent = buildPendingEditContentUri(sessionUri, 'tool-call', join(workspace, 'file.txt')).toString(); + const peerChat = buildChatUri(sessionUri, 'second'); + const peerContent = buildPendingEditContentUri(peerChat, 'peer-tool', join(workspace, 'file.txt')).toString(); + const peerDbContent = buildSessionDbUri(peerChat, 'peer-tool', '/file', 'before'); + const workerChat = buildSubagentChatUri(sessionUri, 'spawn-tool'); + const workerContent = buildPendingEditContentUri(sessionUri, 'worker-tool', join(workspace, 'file.txt')).toString(); + const hiddenContent = buildPendingEditContentUri('copilot:/unknown', 'hidden-tool', join(workspace, 'file.txt')); + for (const [uri, data] of [[pendingContent, 'default preview'], [peerContent, 'peer preview'], [workerContent, 'worker preview'], [hiddenContent.toString(), 'hidden preview']]) { + await previews.mkdir(URI.parse(uri).with({ path: dirname(URI.parse(uri).path) })); + await previews.writeFile(URI.parse(uri), VSBuffer.fromString(data).buffer, { create: true, overwrite: true, unlock: false, atomic: false }); + } + stateManager.addChat(sessionUri, peerChat); + stateManager.addChat(sessionUri, workerChat, { origin: { kind: ChatOriginKind.Tool, chat: buildDefaultChatUri(sessionUri), toolCallId: 'spawn-tool' } }); + for (const [chat, tool, uri] of [[buildDefaultChatUri(sessionUri), 'tool-call', pendingContent], [peerChat, 'peer-tool', peerContent], [workerChat, 'worker-tool', workerContent]]) { + stateManager.dispatchServerAction(chat, { + type: ActionType.ChatTurnStarted, turnId: 'preview-turn', startedAt: '2026-09-30T19:00:00.000Z', + message: { text: 'Preview', origin: { kind: MessageKind.User } }, + }); + stateManager.dispatchServerAction(chat, { type: ActionType.ChatToolCallStart, turnId: 'preview-turn', toolCallId: tool, toolName: 'edit', displayName: 'Edit' }); + stateManager.dispatchServerAction(chat, { + type: ActionType.ChatToolCallReady, turnId: 'preview-turn', toolCallId: tool, invocationMessage: 'Preview', + edits: { items: [{ after: { uri: URI.file(join(workspace, 'file.txt')).toString(), content: { uri } } }] }, + }); + } + const forgedContent = hiddenContent.with({ authority: URI.parse(pendingContent).authority }); + assert.strictEqual(VSBuffer.wrap(await previews.readFile(forgedContent)).toString(), 'hidden preview'); + const rejectedDirectories: string[] = []; + disposables.add(stateManager.onDidRejectClientAction(envelope => { + if (envelope.origin?.clientId === 'scoped-lane' && envelope.origin.clientSeq === 99) { + rejectedDirectories.push(envelope.rejectionReason ?? ''); + } + })); + const cases = [ + { id: 2, method: 'resourceRead', params: { uri: URI.file(join(workspace, 'allowed.txt')).toString() }, allowed: true }, + { id: 3, method: 'resourceRead', params: { uri: URI.file(join(content, 'history.txt')).toString() }, allowed: true }, + { id: 4, method: 'resourceRead', params: { uri: URI.file(join(outside, 'private.txt')).toString() }, allowed: false }, + { id: 5, method: 'resourceRead', params: { uri: URI.file(join(workspace, 'escape', 'private.txt')).toString() }, allowed: false }, + { id: 6, method: 'resourceWrite', params: { uri: URI.file(join(content, 'history.txt')).toString(), data: 'overwrite', encoding: ContentEncoding.Utf8 }, allowed: false }, + { id: 7, method: 'resourceCopy', params: { source: URI.file(join(workspace, 'allowed.txt')).toString(), destination: URI.file(join(outside, 'copy.txt')).toString() }, allowed: false }, + { id: 8, method: 'resourceRead', params: { uri: 'agent-host-edit-attribution:/commit' }, allowed: false }, + { id: 9, method: 'resourceRead', params: { uri: dbContent }, allowed: true }, + { id: 10, method: 'resourceRead', params: { uri: gitContent }, allowed: true }, + { id: 11, method: 'resourceRead', params: { uri: buildSessionDbUri('copilot:/unknown', 'tool-call', join(workspace, 'file.txt'), 'before') }, allowed: false }, + { id: 12, method: 'resourceRead', params: { uri: buildGitBlobUri('copilot:/unknown', 'a'.repeat(40), 'file.txt', join(workspace, 'file.txt')) }, allowed: false }, + { id: 13, method: 'resourceWrite', params: { uri: dbContent, data: 'overwrite', encoding: ContentEncoding.Utf8 }, allowed: false }, + { id: 14, method: 'resourceWrite', params: { uri: URI.file(join(workspace, 'dangling')).toString(), data: 'escape', encoding: ContentEncoding.Utf8 }, allowed: false }, + { id: 15, method: 'resourceWrite', params: { uri: URI.file(join(workspace, 'new.txt')).toString(), data: 'allowed', encoding: ContentEncoding.Utf8 }, allowed: true }, + { id: 16, method: 'createTerminal', params: { cwd: URI.file(workspace).toString() }, allowed: true }, + { id: 17, method: 'createTerminal', params: { cwd: URI.file(join(workspace, 'not-directory.txt')).toString() }, allowed: false }, + { id: 18, method: 'createTerminal', params: { cwd: URI.file(join(workspace, 'missing-directory')).toString() }, allowed: false }, + { id: 19, method: 'resourceRead', params: { uri: buildGitBlobUri(sessionUri, `--output=${join(outside, 'injected')}`, 'file.txt', join(workspace, 'file.txt')) }, allowed: false }, + { id: 20, method: 'resourceRead', params: { uri: gitHeadContent }, allowed: true }, + { id: 21, method: 'resourceRead', params: { uri: pendingContent }, allowed: true }, + { id: 22, method: 'resourceRead', params: { uri: buildPendingEditContentUri('copilot:/unknown', 'tool-call', join(workspace, 'file.txt')).toString() }, allowed: false }, + { id: 23, method: 'resourceWrite', params: { uri: pendingContent, data: 'overwrite', encoding: ContentEncoding.Utf8 }, allowed: false }, + { id: 24, method: 'resourceRead', params: { uri: 'pending-edit-content://not-hex/tool/abcd' }, allowed: false }, + { id: 25, method: 'resourceRead', params: { uri: forgedContent.toString() }, allowed: false }, + { id: 26, method: 'resourceRead', params: { uri: peerContent }, allowed: true }, + { id: 27, method: 'resourceRead', params: { uri: buildPendingEditContentUri(buildChatUri('copilot:/unknown', 'second'), 'peer-tool', '/file').toString() }, allowed: false }, + { id: 28, method: 'resourceRead', params: { uri: buildPendingEditContentUri('ahp-chat://broken', 'peer-tool', '/file').toString() }, allowed: false }, + { id: 29, method: 'resourceRead', params: { uri: peerDbContent }, allowed: true }, + { id: 30, method: 'resourceRead', params: { uri: buildSessionDbUri(buildChatUri('copilot:/unknown', 'second'), 'peer-tool', '/file', 'before') }, allowed: false }, + { id: 31, method: 'resourceRead', params: { uri: buildSessionDbUri('ahp-chat://broken', 'peer-tool', '/file', 'before') }, allowed: false }, + { id: 33, method: 'resourceRead', params: { uri: workerContent }, allowed: true }, + { id: 34, method: 'resourceWrite', params: { uri: URI.file(join(workspace, 'not-directory.txt', 'child')).toString(), data: 'invalid', encoding: ContentEncoding.Utf8 }, allowed: false }, + ]; + for (const item of cases) { + transport.simulateMessage(request(item.id, item.method, { channel: ROOT_STATE_URI, ...item.params })); + } + transport.simulateMessage(notification('dispatchAction', { + channel: sessionUri, clientSeq: 99, + action: { type: ActionType.SessionWorkingDirectorySet, directory: URI.file(join(workspace, 'not-directory.txt', 'child')).toString() }, + })); + await scopedHandler.whenIdle(); + assert.deepStrictEqual({ + results: cases.map(item => { + const response = findResponse(transport.sent, item.id); + return { id: item.id, allowed: !!response && hasKey(response, { result: true }) }; + }), + reads, + writes, + terminalDirectories, + rejectedDirectories, + deniedCodes: cases.filter(item => !item.allowed).map(item => { + const response = findResponse(transport.sent, item.id); + return response && hasKey(response, { error: true }) ? response.error.code : undefined; + }), + }, { + results: cases.map(item => ({ id: item.id, allowed: item.allowed })), + reads: [cases[0].params.uri, cases[1].params.uri, dbContent, gitContent, gitHeadContent, pendingContent, peerContent, peerDbContent, workerContent], + writes: [URI.file(join(workspace, 'new.txt')).toString()], + terminalDirectories: [URI.file(workspace).toString()], + rejectedDirectories: ['Working directory is outside the host workspace grants'], + deniedCodes: cases.filter(item => !item.allowed).map(() => AhpErrorCodes.PermissionDenied), + }); + for (const { id, data } of [{ id: 21, data: 'default preview' }, { id: 26, data: 'peer preview' }, { id: 33, data: 'worker preview' }]) { + assert.deepStrictEqual(findResponse(transport.sent, id), { jsonrpc: '2.0', id, result: { data, encoding: ContentEncoding.Utf8 } }); + } + stateManager.dispatchServerAction(peerChat, { + type: ActionType.ChatToolCallConfirmed, turnId: 'preview-turn', toolCallId: 'peer-tool', approved: false, + reason: ToolCallCancellationReason.Denied, + }); + transport.simulateMessage(request(32, 'resourceRead', { uri: peerContent })); + await scopedHandler.whenIdle(); + const retiredPreview = findResponse(transport.sent, 32); + assert.strictEqual(retiredPreview && hasKey(retiredPreview, { error: true }) ? retiredPreview.error.code : undefined, AhpErrorCodes.PermissionDenied); + assert.strictEqual(reads.filter(uri => uri === peerContent).length, 1); + } finally { + await rm(path, { recursive: true }); + } + }); + + test('unauthenticated relay handshake omits session snapshots without rejecting fresh initialization', () => { + stateManager.createSession(makeSessionSummary()); + const transport = disposables.add(new MockProtocolTransport(AgentHostTransportKind.WebSocket, 'relay-client', false)); + transport.relayAuthenticated = false; + server.simulateConnection(transport); + transport.simulateMessage(request(1, 'initialize', { clientId: 'relay-client', protocolVersions: [PROTOCOL_VERSION], initialSubscriptions: [sessionUri] })); + const response = findResponse(transport.sent, 1); + assert.deepStrictEqual(response && hasKey(response, { result: true }) ? (response.result as InitializeResult).snapshots : undefined, []); + }); + + test('relay cannot reuse a local connection identity from another protocol handler', () => { + connectClient('local-client'); + const relay = disposables.add(new MockProtocolServer()); + disposables.add(new ProtocolServerHandler( + agentService, stateManager, relay, { allowExtensionMethods: false }, + disposables.add(new AgentHostFileSystemProvider()), logService, NullTelemetryService, + managedSettingsService, clientConnections, devContainerService, NullAgentHostOTelService, + )); + const transport = disposables.add(new MockProtocolTransport(AgentHostTransportKind.WebSocket, 'local-client', false)); + relay.simulateConnection(transport); + transport.simulateMessage(request(1, 'initialize', { clientId: 'local-client', protocolVersions: [PROTOCOL_VERSION] })); + const response = findResponse(transport.sent, 1); + assert.deepStrictEqual(response && hasKey(response, { error: true }) ? response.error : undefined, { + code: JsonRpcErrorCodes.InvalidParams, message: 'Relay client ID collides with another host connection', + }); + }); + + test('request-form dispatch waits for delayed application instead of returning a stale sequence', async () => { + stateManager.createSession(makeSessionSummary()); + const transport = connectClient('client-1', [sessionUri]); + const barrier = agentService.dispatchBarrier = new DeferredPromise(); + transport.simulateMessage(request(2, 'dispatchAction', { + channel: sessionUri, clientSeq: 1, action: { + type: ActionType.SessionActiveClientSet, activeClient: { clientId: 'client-1', tools: [] }, + } + })); + const beforeApplication = findResponse(transport.sent, 2); + await barrier.complete(); + await handler.whenIdle(); + const envelope = findNotifications(transport.sent, 'action').at(-1)?.params as ActionEnvelope; + assert.deepStrictEqual({ beforeApplication, response: findResponse(transport.sent, 2) }, { + beforeApplication: undefined, response: { jsonrpc: '2.0', id: 2, result: { serverSeq: envelope.serverSeq } }, + }); + }); + + test('request-form dispatch correlates concurrent replies without changing notification dispatch', async () => { + stateManager.createSession(makeSessionSummary()); + const first = connectClient('client-1', [sessionUri]); + const second = connectClient('client-2', [sessionUri]); + first.sent.length = 0; + second.sent.length = 0; + const barrier = agentService.dispatchBarrier = new DeferredPromise(); + first.simulateMessage(request(2, 'dispatchAction', { + channel: sessionUri, clientSeq: 1, action: { + type: ActionType.SessionActiveClientSet, activeClient: { clientId: 'client-1', tools: [] }, + } + })); + second.simulateMessage(request(2, 'dispatchAction', { + channel: sessionUri, clientSeq: 1, action: { + type: ActionType.SessionActiveClientSet, activeClient: { clientId: 'client-2', tools: [] }, + } + })); + const secondSequence = stateManager.serverSeq; + await barrier.complete(); + const firstSequence = stateManager.serverSeq; + second.simulateMessage(notification('dispatchAction', { + channel: sessionUri, clientSeq: 2, action: { + type: ActionType.SessionTitleChanged, title: 'Notification still applies synchronously', + } + })); + const titleBeforeResponses = stateManager.getSessionState(sessionUri)?.title; + await handler.whenIdle(); + assert.deepStrictEqual({ + firstResponse: findResponse(first.sent, 2), + secondResponse: findResponse(second.sent, 2), + titleBeforeResponses, + dispatchCount: agentService.handledActions.length, + secondResponseCount: second.sent.filter(message => isJsonRpcResponse(message)).length, + }, { + firstResponse: { jsonrpc: '2.0', id: 2, result: { serverSeq: firstSequence } }, + secondResponse: { jsonrpc: '2.0', id: 2, result: { serverSeq: secondSequence } }, + titleBeforeResponses: 'Notification still applies synchronously', + dispatchCount: 3, + secondResponseCount: 1, + }); + }); + + test('request-form dispatch reports a processing error instead of acknowledging another action', async () => { + stateManager.createSession(makeSessionSummary()); + const transport = connectClient('client-1', [sessionUri]); + agentService.dispatchAction = () => { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'Dispatch validation failed'); + }; + transport.sent.length = 0; + transport.simulateMessage(request(2, 'dispatchAction', { + channel: sessionUri, clientSeq: 1, action: { + type: ActionType.SessionActiveClientSet, activeClient: { clientId: 'client-1', tools: [] }, + } + })); + await handler.whenIdle(); + assert.deepStrictEqual({ + response: findResponse(transport.sent, 2), + echoes: findNotifications(transport.sent, 'action'), + }, { + response: { jsonrpc: '2.0', id: 2, error: { code: JsonRpcErrorCodes.InvalidParams, message: 'Dispatch validation failed' } }, + echoes: [], + }); + }); + + test('request-form dispatch fails explicitly when processing produces no authoritative echo', async () => { + stateManager.createSession(makeSessionSummary()); + const transport = connectClient('client-1', [sessionUri]); + agentService.dispatchAction = () => { }; + transport.simulateMessage(request(2, 'dispatchAction', { + channel: sessionUri, clientSeq: 1, action: { + type: ActionType.SessionActiveClientSet, activeClient: { clientId: 'client-1', tools: [] }, + } + })); + await handler.whenIdle(); + assert.deepStrictEqual(findResponse(transport.sent, 2), { + jsonrpc: '2.0', id: 2, error: { code: JSON_RPC_INTERNAL_ERROR, message: 'Action completed without an authoritative acknowledgement' }, + }); + }); + + test('the compatibility workaround does not promote other notifications to requests', async () => { + stateManager.createSession(makeSessionSummary()); + const transport = connectClient('client-1', [sessionUri]); + transport.simulateMessage(request(2, 'action', { channel: sessionUri, action: { type: ActionType.SessionTitleChanged, title: 'Do not apply' } })); + await handler.whenIdle(); + assert.deepStrictEqual({ + response: findResponse(transport.sent, 2), + dispatched: agentService.handledActions, + }, { + response: { jsonrpc: '2.0', id: 2, error: { code: JsonRpcErrorCodes.MethodNotFound, message: 'Method not found: action' } }, + dispatched: [], + }); + }); + + test('request-form dispatch echoes server-only rejection and acknowledges its sequence', async () => { + stateManager.createSession(makeSessionSummary()); + const transport = connectClient('client-1', [sessionUri]); + transport.simulateMessage(request(2, 'dispatchAction', { channel: sessionUri, clientSeq: 1, action: { type: ActionType.SessionReady } })); + await handler.whenIdle(); + const envelope = findNotifications(transport.sent, 'action').at(-1)?.params as ActionEnvelope; + assert.deepStrictEqual({ + response: findResponse(transport.sent, 2), reason: envelope.rejectionReason, dispatched: agentService.handledActions, + }, { + response: { jsonrpc: '2.0', id: 2, result: { serverSeq: envelope.serverSeq } }, + reason: `Server-only action: ${ActionType.SessionReady}`, dispatched: [], + }); + }); + + test('request-form dispatch validates params and returns a missing-resource sentinel', async () => { + const transport = connectClient('client-1'); + transport.simulateMessage(request(2, 'dispatchAction', { + channel: sessionUri, clientSeq: 1, action: { + type: ActionType.SessionActiveClientSet, activeClient: { clientId: 'client-1', tools: [] }, + } + })); + transport.simulateMessage(request(3, 'dispatchAction', { channel: sessionUri, action: {} })); + await handler.whenIdle(); + const invalid = findResponse(transport.sent, 3); + assert.deepStrictEqual({ + missing: findResponse(transport.sent, 2), invalidCode: invalid && hasKey(invalid, { error: true }) ? invalid.error.code : undefined, + dispatched: agentService.handledActions, + }, { + missing: { jsonrpc: '2.0', id: 2, result: { serverSeq: -1 } }, invalidCode: JsonRpcErrorCodes.InvalidParams, dispatched: [], + }); + }); + + test('request-form unsubscribe removes the same subscription as its notification form', async () => { + stateManager.createSession(makeSessionSummary()); + const transport = connectClient('client-1', [sessionUri]); + transport.simulateMessage(request(2, 'unsubscribe', { channel: sessionUri })); + await handler.whenIdle(); + assert.deepStrictEqual({ response: findResponse(transport.sent, 2), unsubscribes: agentService.unsubscribeCalls }, { + response: { jsonrpc: '2.0', id: 2, result: null }, unsubscribes: [{ resource: sessionUri, clientId: 'client-1' }], + }); + }); + + test('request-form dispatch preserves passive rejection and does not mutate the session', async () => { + stateManager.createSession(makeSessionSummary()); + const transport = disposables.add(new MockProtocolTransport(AgentHostTransportKind.Unknown, 'passive-client', true)); + server.simulateConnection(transport); + transport.simulateMessage(request(1, 'initialize', { clientId: 'passive-client', protocolVersions: [PROTOCOL_VERSION], initialSubscriptions: [sessionUri] })); + transport.simulateMessage(request(2, 'dispatchAction', { + channel: sessionUri, clientSeq: 1, action: { + type: ActionType.SessionActiveClientSet, activeClient: { clientId: 'passive-client', tools: [] }, + } + })); + await handler.whenIdle(); + const envelope = findNotifications(transport.sent, 'action').at(-1)?.params as ActionEnvelope; + assert.deepStrictEqual({ + response: findResponse(transport.sent, 2), reason: envelope.rejectionReason, activeClients: stateManager.getSessionState(sessionUri)?.activeClients, + }, { + response: { jsonrpc: '2.0', id: 2, result: { serverSeq: envelope.serverSeq } }, + reason: 'Passive relay connections cannot mutate state', activeClients: [], + }); + }); + + test('request-form dispatch stops waiting and releases acknowledgement listeners on disconnect', async () => { + stateManager.createSession(makeSessionSummary()); + const transport = connectClient('client-1', [sessionUri]); + const barrier = agentService.dispatchBarrier = new DeferredPromise(); + transport.simulateMessage(request(2, 'dispatchAction', { + channel: sessionUri, clientSeq: 1, action: { + type: ActionType.SessionActiveClientSet, activeClient: { clientId: 'client-1', tools: [] }, + } + })); + transport.simulateClose(); + await handler.whenIdle(); + await barrier.complete(); + assert.strictEqual(findResponse(transport.sent, 2), undefined); + }); + + test('request-form dispatch bounds its acknowledgement wait', async () => { + return runWithFakedTimers({ useFakeTimers: true }, async () => { + stateManager.createSession(makeSessionSummary()); + const transport = connectClient('client-1', [sessionUri]); + const barrier = agentService.dispatchBarrier = new DeferredPromise(); + transport.simulateMessage(request(2, 'dispatchAction', { + channel: sessionUri, clientSeq: 1, action: { + type: ActionType.SessionActiveClientSet, activeClient: { clientId: 'client-1', tools: [] }, + } + })); + await handler.whenIdle(); + await barrier.complete(); + const response = findResponse(transport.sent, 2); + assert.deepStrictEqual(response && hasKey(response, { error: true }) ? response.error : undefined, { + code: JSON_RPC_INTERNAL_ERROR, message: 'Action acknowledgement timed out; outcome is unknown', + }); + }); + }); + test('server-only session actions are rejected, not dispatched', () => { stateManager.createSession(makeSessionSummary()); stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady }); @@ -2153,6 +2979,105 @@ suite('ProtocolServerHandler', () => { assert.strictEqual(findNotifications(transportB.sent, 'root/sessionAdded').length, 1); }); + test('mixed identities are visible to standard clients but not identified legacy VS Code clients', async () => { + for (const provider of ['copilotcli', 'codex', 'claude']) { + for (const scheme of [provider, 'ahp-session']) { + agentService.listedSessions.push({ session: URI.parse(`${scheme}:/${provider}-${scheme}`), provider, startTime: 1000, modifiedTime: 2000 }); + } + } + const clients = [ + connectClient('legacy-editor', ['ahp-root://'], editorWindowAgentHostClientInfo), + connectClient('legacy-agents', ['ahp-root://'], agentsWindowAgentHostClientInfo, { [AgentHostSessionUrisCapabilityMetaKey]: 'true' }), + connectClient('new-vscode', ['ahp-root://'], editorWindowAgentHostClientInfo, { [AgentHostSessionUrisCapabilityMetaKey]: true }), + connectClient('external', ['ahp-root://']), + ]; + const listed = []; + for (const client of clients) { + const pending = waitForResponse(client, 2); + client.simulateMessage(request(2, 'listSessions', { channel: 'ahp-root://' })); + const response = await pending; + assert.ok(isJsonRpcResponse(response) && hasKey(response, { result: true })); + const result = response.result as ListSessionsResult; + listed.push(result.items.map(item => [item.provider, item.resource])); + } + const summary = { ...makeSessionSummary('ahp-session:/new'), provider: 'codex' }; + for (const client of clients) { + client.sent.length = 0; + } + stateManager.createSession(summary); + assert.deepStrictEqual({ + listed, + added: clients.map(client => client.sent.filter(message => isJsonRpcNotification(message) && message.method === 'root/sessionAdded').length), + }, { + listed: [clients[0], clients[1], clients[2], clients[3]].map((_, index) => + agentService.listedSessions.filter(meta => index >= 2 || meta.session.scheme !== 'ahp-session').map(meta => [meta.provider, meta.session.toString()])), + added: [0, 0, 1, 1], + }); + }); + + test('new host honors old client-chosen creation resources and declares standard identity support', async () => { + const client = connectClient('old-vscode', ['ahp-root://'], editorWindowAgentHostClientInfo); + const initialize = findResponse(client.sent, 1); + assert.ok(initialize && isJsonRpcResponse(initialize) && hasKey(initialize, { result: true })); + assert.strictEqual(supportsAgentHostSessionUris(initialize.result as InitializeResult), true); + const pending = waitForResponse(client, 2); + client.simulateMessage(request(2, 'createSession', { channel: 'claude:/chosen-by-old-client', provider: 'claude' })); + await pending; + assert.deepStrictEqual(agentService.createSessionConfigs.at(-1)?.session?.toString(), 'claude:/chosen-by-old-client'); + }); + + test('initial snapshots and reconnect preserve connection-scoped legacy visibility', async () => { + const legacy = 'codex:/historical'; + const standard = 'ahp-session:/fresh'; + for (const resource of [legacy, standard]) { + stateManager.createSession({ ...makeSessionSummary(resource), provider: 'codex' }); + agentService.listedSessions.push({ session: URI.parse(resource), provider: 'codex', startTime: 1000, modifiedTime: 2000 }); + } + const results = []; + for (const [clientId, clientInfo, meta] of [ + ['legacy', editorWindowAgentHostClientInfo, undefined], + ['modern', agentsWindowAgentHostClientInfo, { [AgentHostSessionUrisCapabilityMetaKey]: true }], + ['unmarked', undefined, undefined], + ] as const) { + const initial = connectClient(clientId, ['ahp-root://', legacy, standard], clientInfo, meta); + const initialized = findResponse(initial.sent, 1); + assert.ok(initialized && isJsonRpcResponse(initialized) && hasKey(initialized, { result: true })); + const initialResources = (initialized.result as InitializeResult).snapshots.map(snapshot => snapshot.resource); + const legacyCreation = clientConnections.usesLegacySessionUris(clientId); + initial.simulateClose(); + const resumed = new MockProtocolTransport(); + server.simulateConnection(resumed); + const reconnecting = waitForResponse(resumed, 2); + resumed.simulateMessage(request(2, 'reconnect', { channel: 'ahp-root://', clientId, lastSeenServerSeq: stateManager.serverSeq, subscriptions: ['ahp-root://'], _meta: meta })); + await reconnecting; + const listing = waitForResponse(resumed, 3); + resumed.simulateMessage(request(3, 'listSessions', { channel: 'ahp-root://' })); + const response = await listing; + assert.ok(isJsonRpcResponse(response) && hasKey(response, { result: true })); + results.push({ initialResources, legacyCreation, resumedLegacyCreation: clientConnections.usesLegacySessionUris(clientId), sessions: (response.result as ListSessionsResult).items.map(item => item.resource) }); + resumed.simulateClose(); + } + assert.deepStrictEqual(results, [ + { initialResources: ['ahp-root://', legacy], legacyCreation: true, resumedLegacyCreation: true, sessions: [legacy] }, + ...Array.from({ length: 2 }, () => ({ initialResources: ['ahp-root://', legacy, standard], legacyCreation: false, resumedLegacyCreation: false, sessions: [legacy, standard] })), + ]); + }); + + test('standard session addition, summary changes and removal are hidden only from legacy VS Code', () => { + const legacy = connectClient('old', ['ahp-root://'], editorWindowAgentHostClientInfo); + const standard = connectClient('external-standard', ['ahp-root://']); + legacy.sent.length = 0; + standard.sent.length = 0; + const summary = { ...makeSessionSummary('ahp-session:/notifications'), provider: 'claude' }; + stateManager.announceSurfacedSession(summary); + stateManager.updateSurfacedSessionTitle(summary.resource, 'Retitled'); + stateManager.retractSurfacedSession(summary.resource); + assert.deepStrictEqual([legacy, standard].map(client => client.sent.filter(isJsonRpcNotification).map(message => message.method)), [ + [], + ['root/sessionAdded', 'root/sessionSummaryChanged', 'root/sessionRemoved'], + ]); + }); + test('listSessions includes project metadata', async () => { agentService.listedSessions.push({ session: URI.parse(sessionUri), @@ -2459,6 +3384,26 @@ suite('ProtocolServerHandler', () => { assert.deepStrictEqual(result.items.map(item => readSessionExternal(item._meta)), [true]); }); + test('listSessions carries the provider-qualified Codex model on namespaced _meta', async () => { + agentService.listedSessions.push({ + session: URI.parse(sessionUri), + startTime: 1000, + modifiedTime: 2000, + summary: 'Codex Session', + model: { id: '@provider=openai:gpt-5.6-sol' }, + _meta: withCodexSessionModel(undefined, { id: '@provider=openai:gpt-5.6-sol' }), + }); + + const transport = connectClient('client-list-codex-model'); + transport.sent.length = 0; + const responsePromise = waitForResponse(transport, 2); + transport.simulateMessage(request(2, 'listSessions')); + const response = await responsePromise; + const result = (response as unknown as { result: ListSessionsResult }).result; + + assert.deepStrictEqual(result.items.map(item => readCodexSessionModel(item)), [{ id: '@provider=openai:gpt-5.6-sol' }]); + }); + test('listSessions carries ordered lightweight chats and default chat identity', async () => { const defaultChat = URI.parse(`${sessionUri}/chat/default`); const peerChat = URI.parse(`${sessionUri}/chat/peer`); diff --git a/src/vs/platform/agentHost/test/node/serverToolGroups.test.ts b/src/vs/platform/agentHost/test/node/serverToolGroups.test.ts index 9937e8af939c0d..ccc06bb432699f 100644 --- a/src/vs/platform/agentHost/test/node/serverToolGroups.test.ts +++ b/src/vs/platform/agentHost/test/node/serverToolGroups.test.ts @@ -84,13 +84,13 @@ suite('serverToolGroups display', () => { isolated: confirmation(true), }, { direct: { - title: 'Continue in app?', - message: 'Continue this session in /workspace/app and make changes directly in that folder?', + title: 'Change Workspace to app?', + message: 'Change this chat\'s workspace to /workspace/app and make changes directly in that folder? Other chats keep their workspaces.', hideInput: true, }, isolated: { - title: 'Continue in app?', - message: 'Continue this session in /workspace/app with changes isolated from the existing folder?', + title: 'Change Workspace to app?', + message: 'Change this chat\'s workspace to a new worktree of /workspace/app? Other chats keep their workspaces.', hideInput: true, }, }); diff --git a/src/vs/platform/agentHost/test/node/sessionIsolationTools.test.ts b/src/vs/platform/agentHost/test/node/sessionIsolationTools.test.ts new file mode 100644 index 00000000000000..95ca538697d2df --- /dev/null +++ b/src/vs/platform/agentHost/test/node/sessionIsolationTools.test.ts @@ -0,0 +1,103 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { SessionServerToolName } from '../../common/serverToolNames.js'; +import { ActionType } from '../../common/state/sessionActions.js'; +import { buildChatUri, buildDefaultChatUri, buildSubagentSessionUri, MessageKind, SessionStatus } from '../../common/state/sessionState.js'; +import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; +import { AgentServerToolHost } from '../../node/shared/agentServerToolHost.js'; +import { createSessionIsolationToolGroup } from '../../node/shared/sessionIsolationTools.js'; +import { getServerToolDisplay } from '../../node/shared/serverToolGroups.js'; + +suite('Session isolation tool', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function createHarness() { + const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + const session = 'copilotcli:/session'; + const main = buildDefaultChatUri(session); + const peer = buildChatUri(session, 'peer'); + stateManager.createSession({ + resource: session, provider: 'copilotcli', title: 'Project', status: SessionStatus.Idle, + createdAt: new Date(0).toISOString(), modifiedAt: new Date(0).toISOString(), + }); + stateManager.addChat(session, peer); + const calls: { chat: string; turnId: string }[] = []; + let enabled = true; + const group = createSessionIsolationToolGroup({ + supportsChatIsolation: () => enabled, + requestChatIsolation: (chat, turnId) => calls.push({ chat: chat.toString(), turnId }), + }); + const host = new AgentServerToolHost(stateManager, [group]); + host.advertise(session); + return { stateManager, host, group, session, main, peer, calls, disable: () => { enabled = false; } }; + } + + test('advertises to the session but rejects subagent calls', () => { + const { host, session, main } = createHarness(); + const childSession = buildSubagentSessionUri(URI.parse(session), 'worker').toString(); + const child = buildDefaultChatUri(childSession); + assert.deepStrictEqual({ + session: host.getDefinitionsForSession(session).map(tool => tool.name), + child: host.getDefinitionsForSession(childSession), + }, { session: [SessionServerToolName.IsolateSession], child: [] }); + assert.throws(() => host.executeTool(child, SessionServerToolName.IsolateSession, {}), /disabled/); + assert.strictEqual(host.requiresConfirmation(main, SessionServerToolName.IsolateSession), true); + }); + + for (const target of ['main', 'peer'] as const) { + test(`requests isolation from an active ${target} turn with no workspace arguments`, () => { + const harness = createHarness(); + const { stateManager, host, calls } = harness; + const chat = harness[target]; + stateManager.dispatchServerAction(chat, { + type: ActionType.ChatTurnStarted, turnId: 'turn-1', startedAt: new Date().toISOString(), + message: { text: 'Continue in isolation', origin: { kind: MessageKind.User } }, + }); + assert.throws(() => host.executeTool(chat, SessionServerToolName.IsolateSession, { workspace: '/other' }), /no arguments/); + const result = host.executeTool(chat, SessionServerToolName.IsolateSession, {}); + assert.deepStrictEqual(calls, [{ chat, turnId: 'turn-1' }]); + assert.match(String(result), /End this turn.*only this chat.*automatically/); + }); + } + + test('does not execute outside a turn or after isolation is no longer available', () => { + const { host, session, main, disable } = createHarness(); + assert.throws(() => host.executeTool(main, SessionServerToolName.IsolateSession, {}), /active turn/); + disable(); + host.advertise(session); + assert.deepStrictEqual(host.getDefinitionsForSession(session), []); + assert.throws(() => host.executeTool(main, SessionServerToolName.IsolateSession, {}), /disabled/); + }); + + test('refreshes tool availability on an existing session instead of pinning initial membership', () => { + const { stateManager, host, session } = createHarness(); + stateManager.dispatchServerAction(session, { type: ActionType.SessionServerToolsChanged, tools: [] }); + host.advertise(session); + assert.deepStrictEqual(stateManager.getSessionState(session)?.serverTools?.map(tool => tool.name), [SessionServerToolName.IsolateSession]); + }); + + test('requires confirmation and explains chat-only effects and turn ordering', () => { + const { host, group, main } = createHarness(); + const tool = group.definitions[0]; + assert.deepStrictEqual({ + canConfirm: host.canRequireConfirmation(tool.name), + confirms: host.requiresConfirmation(main, tool.name), + parameters: tool.inputSchema, + display: getServerToolDisplay(tool.name, {})?.displayName, + }, { + canConfirm: true, confirms: true, parameters: { type: 'object', properties: {} }, display: 'Change Workspace to a New Worktree', + }); + assert.deepStrictEqual(Object.values(getServerToolDisplay(tool.name, {})!).filter(value => typeof value === 'string' && /isolat/i.test(value)), []); + assert.match(tool.description!, /only the current chat/); + assert.match(tool.description!, /original folder is unchanged/); + assert.match(tool.description!, /does not.*move other chats/); + assert.match(tool.description!, /final tool call.*end the turn/); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts b/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts index 484c2ec7dddb6e..f1f0edaadf6678 100644 --- a/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts @@ -91,7 +91,7 @@ suite('SessionServerTools', () => { getChatContext: overrides?.getChatContext ?? (async () => undefined), getSessionSpawnDepth: overrides?.getSessionSpawnDepth ?? (session => depths.get(session.toString()) ?? 0), setSessionSpawnDepth: overrides?.setSessionSpawnDepth ?? ((session, depth) => { depths.set(session.toString(), depth); }), - requestSessionWorkspaceUpdate: overrides?.requestSessionWorkspaceUpdate ?? (() => { }), + requestSessionWorkspaceUpdate: overrides?.requestSessionWorkspaceUpdate ?? (() => true), }; } @@ -144,17 +144,15 @@ suite('SessionServerTools', () => { const setWorkspaceDefinition = sessionServerToolDefinitions.find(def => def.name === SessionServerToolName.SetWorkspace); assert.deepStrictEqual({ title: setWorkspaceDefinition?.title, - description: setWorkspaceDefinition?.description, inputSchema: setWorkspaceDefinition?.inputSchema, }, { title: 'Set Workspace', - description: 'Attach a real workspace only to modify its files or run commands requiring its project environment. Do not use for self-contained scratch work on attachments, pasted/generated content, or throwaway/exportable artifacts. The session, chat, and history are preserved. Immediately before every call, use the available user-input tool to ask one question confirming both workspace and isolation, even if already specified; tool approval is not confirmation. Set `isolation` to true for a managed Git worktree or false for the folder directly. After this turn, the host attaches the workspace and continues the original task. Make this the turn\'s final tool call.', inputSchema: { type: 'object', properties: { workspaceFolder: { type: 'string', - description: 'Absolute local folder path or file URI to set as the current session\'s workspace. Use an exact path from the user or `list_sessions`; do not guess.', + description: 'Absolute local folder path or file URI to set as the current chat\'s workspace. Use an exact path from the user or `list_sessions`; do not guess.', }, isolation: { type: 'boolean', @@ -177,6 +175,24 @@ suite('SessionServerTools', () => { assert.ok(renameDescription?.includes('peer-chat titles remain independent')); }); + test('set_workspace requires explicit intent for existing workspaces and preserves quick-chat setup guidance', () => { + const description = sessionServerToolDefinitions.find(def => def.name === SessionServerToolName.SetWorkspace)!.description; + for (const clause of [ + 'only when the user explicitly asks to change this chat\'s workspace', + 'Do not infer a workspace change from a file path', + 'skip this tool and the confirmation question', + 'Compare against this chat\'s working directory, not its session\'s project root', + 'Creating a new worktree from the same folder is a real change, not a no-op', + 'For a workspace-less quick chat, attach a workspace only to modify its files or run commands requiring its project environment', + 'Other chats keep their workspaces', + 'ask one question confirming both workspace and isolation', + 'tool approval is not confirmation', + 'the turn\'s final tool call', + ]) { + assert.ok(description?.includes(clause), clause); + } + }); + test('set_workspace accepts only exact local workspace folders', () => { assert.deepStrictEqual({ absolutePath: getSetWorkspaceArgs({ workspaceFolder: '/workspace/app', isolation: false }), @@ -194,12 +210,15 @@ suite('SessionServerTools', () => { const requests: { chat: string; turnId: string; workspaceFolder: string; isolation: boolean }[] = []; const chat = URI.parse(buildDefaultChatUri('copilot:/s1')); const accessor = createAccessor({ - requestSessionWorkspaceUpdate: (targetChat, turnId, workspaceFolder, isolation) => requests.push({ - chat: targetChat.toString(), - turnId, - workspaceFolder: workspaceFolder.toString(), - isolation, - }), + requestSessionWorkspaceUpdate: (targetChat, turnId, workspaceFolder, isolation) => { + requests.push({ + chat: targetChat.toString(), + turnId, + workspaceFolder: workspaceFolder.toString(), + isolation, + }); + return true; + }, }); const result = applySetWorkspaceTool(accessor, { workspaceFolder: '/workspace/app', isolation: true }, chat, 'turn-1'); @@ -219,6 +238,18 @@ suite('SessionServerTools', () => { assert.throws(() => applySetWorkspaceTool(accessor, { workspaceFolder: '/workspace/app', isolation: false }, chat, undefined), /must run from an active chat turn/); }); + test('set_workspace reports an unchanged folder without instructing the agent to end its turn', () => { + const chat = URI.parse(buildDefaultChatUri('copilot:/s1')); + const accessor = createAccessor({ requestSessionWorkspaceUpdate: () => false }); + const args = { workspaceFolder: '/workspace/app', isolation: false }; + const text = applySetWorkspaceTool(accessor, args, chat, 'turn-1'); + const display = createSessionServerToolGroup(accessor).getDisplay?.(SessionServerToolName.SetWorkspace, args, { text, success: true }); + assert.deepStrictEqual({ text, pastTenseMessage: display?.pastTenseMessage }, { + text: 'This chat already uses file:///workspace/app. No workspace change was scheduled. Continue the current turn normally; no automatic continuation will be started. Do not call set_workspace again for this unchanged workspace.', + pastTenseMessage: 'Workspace unchanged', + }); + }); + test('ephemeral sessions advertise no default session-management tools', () => { const stateManager = new AgentHostStateManager(new NullLogService()); const session = 'copilot:/ephemeral'; diff --git a/src/vs/platform/agentHost/test/node/sessionWorkspaceConversion.test.ts b/src/vs/platform/agentHost/test/node/sessionWorkspaceConversion.test.ts index 2b3fe22535ca19..e6db4e805e95eb 100644 --- a/src/vs/platform/agentHost/test/node/sessionWorkspaceConversion.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionWorkspaceConversion.test.ts @@ -7,6 +7,7 @@ import assert from 'assert'; import * as fs from 'fs'; import { tmpdir } from 'os'; import { join } from '../../../../base/common/path.js'; +import { isLinux } from '../../../../base/common/platform.js'; import { DeferredPromise, timeout } from '../../../../base/common/async.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { URI } from '../../../../base/common/uri.js'; @@ -15,6 +16,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { NullLogService } from '../../../log/common/log.js'; import { AgentWorkingDirectoryChangedError, type IAgent } from '../../common/agent.js'; import type { IAgentHostChatContributionContext } from '../../common/agentHostChatContributionsService.js'; +import type { IAgentHostGitStateService } from '../../common/agentHostGitStateService.js'; import { META_DIFF_BASE_BRANCH } from '../../common/agentHostGitService.js'; import { AgentHostGlobalAutoApproveEnabledConfigKey, platformSessionSchema, schemaProperty } from '../../common/agentHostSchema.js'; import { AgentSystemNotificationKind, AgentSystemNotificationWorkspaceKind, readAgentSystemNotificationMeta, serializeAgentWorkspaceTransition } from '../../common/meta/agentSystemNotificationMeta.js'; @@ -22,15 +24,18 @@ 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, withSessionWorkspaceless, type ErrorInfo, type Message, type Turn } from '../../common/state/sessionState.js'; +import { sessionReducer } from '../../common/state/protocol/channels-session/reducer.js'; +import { AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY, AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, buildChatUri, buildDefaultChatUri, ChatInteractivity, ChatOriginKind, createErrorResponsePart, customizationId, CustomizationLoadStatus, CustomizationType, isHostNoticeTurn, isMessageHiddenFromTranscript, isMessageRequestHiddenFromTranscript, MessageKind, readMessageSystemInitiatedLabel, readSessionGitState, readSessionHasWorkspaceTransitions, readSessionWorkspaceless, ResponsePartKind, SessionStatus, TurnState, withSessionGitState, withSessionWorkspaceless, type ErrorInfo, type Message, type SessionState, 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'; import { AgentConfigurationService } from '../../node/agentConfigurationService.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { SessionWorkspaceConversionContribution } from '../../node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionContribution.js'; -import { SessionWorkspaceConversionService, type ISessionWorkspaceConversionService } from '../../node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.js'; -import type { IAgentHostServerToolService } from '../../node/shared/agentServerToolHost.js'; +import { SessionWorkspaceConversionService, type IChatIsolationHost, type ISessionWorkspaceConversionService } from '../../node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.js'; +import { AgentServerToolHost, type IAgentHostServerToolService } from '../../node/shared/agentServerToolHost.js'; +import { createSessionIsolationToolGroup } from '../../node/shared/sessionIsolationTools.js'; +import { SessionServerToolName } from '../../common/serverToolNames.js'; import { NullAgentHostWorktreeIsolation, WORKTREE_META_REPOSITORY_ROOT, type IIsolationConfigContribution, type IResolveIsolationConfigRequest, type IResolveWorkingDirectoryRequest, type ISessionWorktree } from '../../node/shared/worktreeIsolation.js'; import { createSessionDataService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; import { MockAgent } from './mockAgent.js'; @@ -42,6 +47,11 @@ class TestWorktreeIsolation extends NullAgentHostWorktreeIsolation { readonly createdWorktrees: URI[] = []; readonly removedWorktrees: ISessionWorktree[] = []; readonly externalProjectRequests: URI[] = []; + readonly retainedSessions: string[] = []; + + override async retainSessionWorktree(session: URI): Promise { + this.retainedSessions.push(session.toString()); + } constructor(readonly worktree: URI, readonly repository = URI.file('/workspace/project')) { super(); @@ -147,12 +157,14 @@ suite('SessionWorkspaceConversionService', () => { worktreeIsolation = new NullAgentHostWorktreeIsolation(), requestWorkspaceTrust: IAgentHostClientConnectionService['requestWorkspaceTrust'] = async () => true, database: ISessionDatabase = new TestSessionDatabase(), + providerId = 'copilot', + chatIsolationHost?: IChatIsolationHost, ) { const logService = new NullLogService(); const stateManager = disposables.add(new AgentHostStateManager(logService)); const configurationService = disposables.add(new AgentConfigurationService(stateManager, logService)); const sessionDataService = createSessionDataService(database); - const agent = new MockAgent('copilot', { multipleChats: { fork: true } }, { workspaceConversion: true }); + const agent = new MockAgent(providerId, { multipleChats: { fork: true }, multipleWorkingDirectories: { immutablePrimary: true } }, { workspaceConversion: true }); disposables.add({ dispose: () => agent.dispose() }); const providerService = createTestAgentHostProviderService(() => agent); const trustRequests: { clientId: string; workspace: string; trustedParent?: string }[] = []; @@ -212,13 +224,37 @@ suite('SessionWorkspaceConversionService', () => { refreshedServerTools.push(targetSession); } }(); - const service = disposables.add(new SessionWorkspaceConversionService(stateManager, providerService, sessionDataService, worktreeIsolation, configurationService, clientConnections, turnService, serverToolHost, logService)); - const session = URI.parse('copilot:/workspace-less'); + const gitRefreshes: string[] = []; + const gitStateService = new class extends mock() { + override getMaterializedWorktreeMeta(session: string, branchName: string) { + return withSessionGitState(stateManager.getSessionState(session)?._meta, { branchName }); + } + override async refreshSessionGitState(_session: string, directory?: URI): Promise { + gitRefreshes.push(directory!.toString()); + } + }(); + const service = disposables.add(new SessionWorkspaceConversionService(chatIsolationHost ?? { + runWithChatCatalogLock: (_session, operation) => operation(), + prepareChatWorkingDirectory: async () => { throw new Error('Single-chat isolation must not require multi-root preparation'); }, + setChatWorkingDirectory: async (session, chat, directory, replaceSessionWorkspace) => { + assert.strictEqual(replaceSessionWorkspace, true); + stateManager.dispatchServerAction(session.toString(), { + type: ActionType.SessionWorkingDirectoryReplaced, + directory: stateManager.getSessionSummary(session.toString())!.workingDirectories![0], + replacement: directory.toString(), + }); + stateManager.dispatchServerAction(chat.toString(), { type: ActionType.ChatWorkingDirectorySet, directory: directory.toString() }); + stateManager.dispatchServerAction(session.toString(), { + type: ActionType.SessionChatUpdated, chat: chat.toString(), changes: { workingDirectories: [directory.toString()] }, + }); + }, + }, stateManager, providerService, sessionDataService, worktreeIsolation, configurationService, clientConnections, turnService, serverToolHost, logService, gitStateService)); + const session = URI.from({ scheme: providerId, path: '/workspace-less' }); const chat = URI.parse(buildDefaultChatUri(session)); const scratch = URI.file('/tmp/copilot-scratch/workspace-less'); stateManager.createSession({ resource: session.toString(), - provider: 'copilot', + provider: providerId, title: 'Workspace-less Session', status: SessionStatus.Idle, createdAt: new Date(0).toISOString(), @@ -226,7 +262,7 @@ suite('SessionWorkspaceConversionService', () => { workingDirectories: [scratch.toString()], _meta: withSessionWorkspaceless(undefined, true), }); - return { service, stateManager, configurationService, sessionDataService, database, agent, session, chat, scratch, continuations, outcomeKindsAtContinuation, deferredContinuations, failedContinuations, trustRequests, refreshedServerTools }; + return { service, stateManager, configurationService, sessionDataService, database, agent, session, chat, scratch, continuations, outcomeKindsAtContinuation, deferredContinuations, failedContinuations, trustRequests, refreshedServerTools, serverToolHost, gitRefreshes }; } function setSessionConfig(harness: ReturnType, values: Record): void { @@ -262,6 +298,1299 @@ suite('SessionWorkspaceConversionService', () => { return harness.service.updateSessionWorkspace(harness.chat.toString(), 'turn-1'); } + /** + * Drives a workspace change the way the tools do: the chat requests it from + * an active turn (`set_workspace`, or `isolate_session` when no folder is + * given) and the host applies it, then continues, once that turn ends. + * Returns whether the request scheduled a change. + */ + async function changeWorkspaceViaTool(harness: ReturnType, chat: URI, workspace: URI | undefined, isolation = false, turnId = 'turn-1'): Promise { + startTurn(harness.stateManager, chat, turnId); + let scheduled = true; + if (workspace) { + scheduled = harness.service.requestSessionWorkspaceUpdate(chat, turnId, workspace, isolation, 'client'); + } else { + harness.service.requestChatIsolation(chat, turnId, 'client'); + } + completeTurn(harness.stateManager, chat, turnId); + await harness.service.updateSessionWorkspace(chat.toString(), turnId); + return scheduled; + } + + function makeFolderSession(harness: ReturnType): void { + harness.stateManager.dispatchServerAction(harness.session.toString(), { type: ActionType.SessionReady }); + harness.stateManager.setSessionMeta(harness.session.toString(), withSessionWorkspaceless(undefined, false)); + const provider: IAgent = harness.agent; + provider.setChatWorkingDirectory = async () => { }; + setSessionConfig(harness, { + [SessionConfigKey.Isolation]: 'folder', + [SessionConfigKey.AutoApprove]: 'default', + [SessionConfigKey.WorktreeIncludeFiles]: ['.env'], + }); + } + + for (const isolation of [false, true]) { + for (const phase of ['provider', 'metadata'] as const) { + test(`preserves config edits during ${phase} for a workspace change (isolation: ${isolation})`, async () => { + let edited = false; + const editConfig = () => { + if (!edited) { + edited = true; + harness.stateManager.dispatchServerAction(harness.session.toString(), { + type: ActionType.SessionConfigChanged, + config: { mode: 'autopilot', autoApprove: 'assisted' }, + }); + } + }; + const database = new class extends TestSessionDatabase { + override async setMetadataValues(values: Readonly>): Promise { + await super.setMetadataValues(values); + if (phase === 'metadata' && values.configValues) { + editConfig(); + } + } + override async setWorkspaceConversion(turnId: string, transition: string, metadata: Readonly>): Promise { + await super.setWorkspaceConversion(turnId, transition, metadata); + if (phase === 'metadata') { + editConfig(); + } + } + }; + const harness = createHarness(new TestWorktreeIsolation(URI.file('/worktree')), async () => true, database); + makeFolderSession(harness); + harness.stateManager.dispatchServerAction(harness.session.toString(), { + type: ActionType.SessionConfigChanged, + config: { mode: 'interactive', branch: 'old-branch' }, + }); + const provider: IAgent = harness.agent; + provider.setChatWorkingDirectory = async () => { + if (phase === 'provider') { + editConfig(); + } + }; + await changeWorkspaceViaTool(harness, harness.chat, URI.file('/workspace/destination'), isolation); + const expected = { + isolation: isolation ? 'worktree' : 'folder', mode: 'autopilot', autoApprove: 'assisted', + worktreeIncludeFiles: ['.env'], ...(isolation ? { branch: 'main' } : {}), + }; + assert.deepStrictEqual({ + host: harness.stateManager.getSessionState(harness.session.toString())?.config?.values, + persisted: JSON.parse((await database.getMetadata('configValues'))!), + }, { host: expected, persisted: expected }); + }); + } + } + + for (const target of ['main', 'peer'] as const) { + for (const sharedFolder of [false, true]) { + test(`isolates only the ${target} chat with ${sharedFolder ? 'shared' : 'different'} folders while another chat is active`, async () => { + const worktree = URI.file('/workspace/project.worktrees/chat'); + const isolation = new TestWorktreeIsolation(worktree); + const host: IChatIsolationHost = { + runWithChatCatalogLock: (_session, operation) => operation(), + prepareChatWorkingDirectory: async (session, source, options) => { + assert.deepStrictEqual({ source: source.toString(), forceNew: options.isolation === 'worktree' && options.forceNewWorktree }, { + source: (target === 'main' || sharedFolder ? harness.scratch : otherFolder).toString(), forceNew: true, + }); + for (const chat of harness.stateManager.getSessionState(session.toString())!.chats) { + const roots = chat.workingDirectories ?? [harness.scratch.toString()]; + harness.stateManager.dispatchServerAction(session.toString(), { + type: ActionType.SessionChatUpdated, chat: chat.resource, changes: { workingDirectories: [...roots] }, + }); + } + harness.stateManager.dispatchServerAction(session.toString(), { type: ActionType.SessionWorkingDirectorySet, directory: worktree.toString() }); + return { directory: worktree, release: async () => { throw new Error('Successful isolation must retain its worktree'); } }; + }, + setChatWorkingDirectory: async (session, chat, directory) => { + harness.stateManager.dispatchServerAction(session.toString(), { + type: ActionType.SessionChatUpdated, chat: chat.toString(), changes: { workingDirectories: [directory.toString()] }, + }); + }, + }; + const harness = createHarness(isolation, async () => true, new TestSessionDatabase(), 'copilot', host); + makeFolderSession(harness); + const otherFolder = URI.file('/workspace/other'); + const peer = URI.parse(buildChatUri(harness.session, 'peer')); + if (!sharedFolder) { + harness.stateManager.dispatchServerAction(harness.session.toString(), { type: ActionType.SessionWorkingDirectorySet, directory: otherFolder.toString() }); + } + harness.stateManager.addChat(harness.session.toString(), peer.toString(), { workingDirectories: [(sharedFolder ? harness.scratch : otherFolder).toString()] }); + harness.stateManager.dispatchServerAction(harness.session.toString(), { + type: ActionType.SessionChatUpdated, chat: harness.chat.toString(), changes: { workingDirectories: [harness.scratch.toString()] }, + }); + const callingChat = target === 'main' ? harness.chat : peer; + const otherChat = target === 'main' ? peer : harness.chat; + const calls: { chat: string; directory: string }[] = []; + const provider: IAgent = harness.agent; + provider.setChatWorkingDirectory = async (chat, _context, directory) => { calls.push({ chat: chat.toString(), directory: directory.toString() }); }; + completePriorTurn(harness.stateManager, callingChat); + startTurn(harness.stateManager, callingChat); + startTurn(harness.stateManager, otherChat, 'other-turn'); + const previous = harness.stateManager.getSessionState(harness.session.toString())!; + harness.service.requestChatIsolation(callingChat, 'turn-1', 'client-1'); + assert.deepStrictEqual([harness.service.isPending(callingChat.toString()), harness.service.isPending(otherChat.toString()), harness.service.isPending(callingChat.toString(), true)], [true, false, false]); + completeTurn(harness.stateManager, callingChat); + await harness.service.updateSessionWorkspace(callingChat.toString(), 'turn-1'); + assert.match(harness.continuations[0]?.message.text ?? '', /Only this chat now uses the worktree/); + const state = harness.stateManager.getSessionState(harness.session.toString())!; + assert.deepStrictEqual({ + calls, sessionRoots: harness.stateManager.getSessionSummary(harness.session.toString())?.workingDirectories, config: state.config, + roots: state.chats.map(chat => [chat.resource, chat.workingDirectories]), + otherTurn: harness.stateManager.getActiveTurnId(otherChat.toString()), + continuations: harness.continuations.map(entry => entry.chat), + available: harness.service.canIsolateChat(callingChat), + persisted: await harness.database.getMetadata('agentHost.chatIsolationDirectory'), + }, { + calls: [{ chat: callingChat.toString(), directory: worktree.toString() }], + sessionRoots: [harness.scratch.toString(), ...sharedFolder ? [] : [otherFolder.toString()], worktree.toString()], config: previous.config, + roots: previous.chats.map(chat => [chat.resource, chat.resource === callingChat.toString() ? [worktree.toString()] : chat.workingDirectories]), + otherTurn: 'other-turn', continuations: [callingChat.toString()], available: false, persisted: worktree.toString(), + }); + }); + } + } + + for (const restoredState of ['isolated', 'quarantined', 'cancelled'] as const) { + test(`restores or cancels ${restoredState} chat isolation without blocking another chat`, async () => { + const worktree = URI.file('/workspace/project.worktrees/chat'); + const host: IChatIsolationHost = { + runWithChatCatalogLock: (_session, operation) => operation(), + prepareChatWorkingDirectory: async () => { throw new Error('No worktree should be prepared'); }, + setChatWorkingDirectory: async () => { throw new Error('No workspace should change'); }, + }; + const harness = createHarness(new TestWorktreeIsolation(worktree), async () => true, new TestSessionDatabase(), 'copilot', host); + makeFolderSession(harness); + const peer = URI.parse(buildChatUri(harness.session, 'peer')); + harness.stateManager.addChat(harness.session.toString(), peer.toString(), { workingDirectories: [harness.scratch.toString()] }); + const provider: IAgent = harness.agent; + provider.setChatWorkingDirectory = async () => { throw new Error('No provider directory should change'); }; + assert.deepStrictEqual([harness.service.canIsolateChat(peer), harness.service.canIsolateChat(harness.chat)], [true, true]); + if (restoredState === 'cancelled') { + startTurn(harness.stateManager, peer); + harness.service.requestChatIsolation(peer, 'turn-1', 'client-1'); + harness.service.cancel(peer.toString(), 'turn-1'); + completeTurn(harness.stateManager, peer); + await harness.service.updateSessionWorkspace(peer.toString(), 'turn-1'); + } else { + await harness.database.setMetadata( + restoredState === 'isolated' ? 'agentHost.chatIsolationDirectory' : 'agentHost.chatIsolationQuarantined', + restoredState === 'isolated' ? worktree.toString() : 'true', + ); + await harness.service.restoreChatIsolation(peer.toString()); + } + assert.deepStrictEqual({ + peerAvailable: harness.service.canIsolateChat(peer), + mainAvailable: harness.service.canIsolateChat(harness.chat), + blocked: harness.service.isPending(peer.toString()), + sessionBlocked: harness.service.isPending(peer.toString(), true), + continuations: harness.continuations, + }, { + peerAvailable: restoredState === 'cancelled', mainAvailable: true, + blocked: restoredState === 'quarantined', sessionBlocked: false, continuations: [], + }); + }); + } + + for (const failure of ['trust', 'destinationTrust', 'provider', 'uncertain', 'commit'] as const) { + test(`chat isolation ${failure} failure does not block unrelated chats and preserves restart safety`, async () => { + const worktree = URI.file('/workspace/project.worktrees/chat'); + const isolation = new TestWorktreeIsolation(worktree); + let releases = 0; + let providerCalls = 0; + const host: IChatIsolationHost = { + runWithChatCatalogLock: (_session, operation) => operation(), + prepareChatWorkingDirectory: async () => ({ directory: worktree, release: async () => { releases++; } }), + setChatWorkingDirectory: async () => { throw new Error('commit failed'); }, + }; + const database = new TestSessionDatabase(); + const harness = createHarness(isolation, async (_client, request) => + failure !== 'trust' && (failure !== 'destinationTrust' || request.workspace !== worktree.toString()), database, 'copilot', host); + makeFolderSession(harness); + const peer = URI.parse(buildChatUri(harness.session, 'peer')); + harness.stateManager.addChat(harness.session.toString(), peer.toString(), { workingDirectories: [harness.scratch.toString()] }); + const provider: IAgent = harness.agent; + provider.setChatWorkingDirectory = async () => { + providerCalls++; + if (failure === 'provider') { + throw new Error('provider rejected before mutation'); + } + if (failure === 'uncertain') { + throw new AgentWorkingDirectoryChangedError(worktree, 'update could not be confirmed'); + } + }; + startTurn(harness.stateManager, peer); + harness.service.requestChatIsolation(peer, 'turn-1', 'client-1'); + completeTurn(harness.stateManager, peer); + await harness.service.updateSessionWorkspace(peer.toString(), 'turn-1'); + const restored = createHarness(isolation, async () => true, database, 'copilot', host); + await restored.service.restoreChatIsolation(peer.toString()); + const unsafe = failure === 'uncertain' || failure === 'commit'; + assert.deepStrictEqual({ + releases, providerCalls, + blocked: harness.service.isPending(peer.toString()), + otherBlocked: harness.service.isPending(harness.chat.toString()), + restoredBlocked: restored.service.isPending(peer.toString()), + sessionQuarantine: await database.getMetadata(AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY), + failedTurns: harness.failedContinuations.length, + }, { + releases: failure === 'provider' || failure === 'destinationTrust' ? 1 : 0, + providerCalls: failure === 'trust' || failure === 'destinationTrust' ? 0 : 1, + blocked: unsafe, otherBlocked: false, restoredBlocked: unsafe, + sessionQuarantine: undefined, failedTurns: unsafe ? 1 : 0, + }); + }); + } + + for (const target of ['single', 'main', 'peer'] as const) { + test(`tool isolation of an idle ${target} chat persists the transition and continues the chat`, async () => { + const worktree = URI.file('/worktree'); + const host: IChatIsolationHost = { + runWithChatCatalogLock: (_session, operation) => operation(), + prepareChatWorkingDirectory: async () => ({ directory: worktree, release: async () => assert.fail('must retain worktree') }), + setChatWorkingDirectory: async (session, chat, directory) => { + harness.stateManager.dispatchServerAction(session.toString(), { + type: ActionType.SessionChatUpdated, chat: chat.toString(), changes: { workingDirectories: [directory.toString()] }, + }); + }, + }; + const harness = createHarness(new TestWorktreeIsolation(worktree), async () => true, new TestSessionDatabase(), 'copilot', target === 'single' ? undefined : host); + makeFolderSession(harness); + const peer = URI.parse(buildChatUri(harness.session, 'peer')); + if (target === 'single') { + harness.agent.getDescriptor = () => ({ provider: 'copilot', displayName: 'Copilot', description: '' }); + } else { + harness.stateManager.addChat(harness.session.toString(), peer.toString(), { workingDirectories: [harness.scratch.toString()] }); + } + const chat = target === 'peer' ? peer : harness.chat; + const other = target === 'main' ? peer : harness.chat; + completePriorTurn(harness.stateManager, chat); + if (target !== 'single') { + startTurn(harness.stateManager, other, 'other-turn'); + } + const calls: string[] = []; + const provider: IAgent = harness.agent; + provider.setChatWorkingDirectory = async resource => { + calls.push(resource.toString()); + const running = harness.stateManager.getChatState(chat.toString())!; + assert.deepStrictEqual({ + activeTurn: running.activeTurn?.id, + inProgress: (running.status & SessionStatus.InProgress) !== 0, + hiddenRequest: running.activeTurn && isMessageRequestHiddenFromTranscript(running.activeTurn.message), + protectedTurn: harness.service.isConversionTurn(chat.toString(), 'continuation-1'), + }, { activeTurn: 'continuation-1', inProgress: true, hiddenRequest: true, protectedTurn: true }); + }; + await changeWorkspaceViaTool(harness, chat, undefined); + assert.deepStrictEqual({ + calls, turns: harness.stateManager.getChatState(chat.toString())!.turns.map(turn => turn.id), + active: harness.stateManager.getActiveTurnId(chat.toString()), + otherActive: target === 'single' ? undefined : harness.stateManager.getActiveTurnId(other.toString()), + continue: harness.continuations.map(entry => entry.chat), + persisted: await harness.database.getMetadata('agentHost.chatIsolationDirectory'), + transition: (await harness.database.getTurnWorkspaceTransitions()).get('continuation-1'), + transitions: readSessionHasWorkspaceTransitions(harness.stateManager.getSessionState(harness.session.toString())?._meta), + }, { + calls: [chat.toString()], turns: ['turn-0', 'turn-1'], + active: 'continuation-1', otherActive: target === 'single' ? undefined : 'other-turn', + continue: [chat.toString()], persisted: worktree.toString(), + transition: serializeAgentWorkspaceTransition({ + content: 'Workspace changed to a new worktree of workspace-less', + workspaceKind: AgentSystemNotificationWorkspaceKind.Worktree, + workspaceName: 'workspace-less', + }), + transitions: target !== 'peer', + }); + }); + } + + for (const source of ['folder', 'worktree'] as const) { + test(`workspace tool changes a ${source} chat to an existing folder without multi-root support`, async () => { + const worktrees = new TestWorktreeIsolation(URI.file('/old-worktree')); + const harness = createHarness(worktrees); + makeFolderSession(harness); + harness.agent.getDescriptor = () => ({ provider: 'copilot', displayName: 'Copilot', description: '' }); + if (source === 'worktree') { + setSessionConfig(harness, { [SessionConfigKey.Isolation]: 'worktree' }); + } + completePriorTurn(harness.stateManager, harness.chat); + const workspace = URI.file('/workspace/destination'); + const calls: string[][] = []; + const provider: IAgent = harness.agent; + provider.setChatWorkingDirectory = async (chat, _context, directory, options) => { + assert.deepStrictEqual(options, { replaceSessionWorkspace: true }); + calls.push([chat.toString(), directory.toString()]); + assert.deepStrictEqual({ + blocked: harness.service.isPending(chat.toString()), + }, { blocked: true }); + }; + await changeWorkspaceViaTool(harness, harness.chat, workspace); + const state = harness.stateManager.getSessionState(harness.session.toString())!; + assert.deepStrictEqual({ + calls, + transition: (await harness.database.getTurnWorkspaceTransitions()).get('continuation-1'), + sessionDirectory: harness.stateManager.getSessionSummary(harness.session.toString())?.workingDirectories, chatDirectory: state.chats[0].workingDirectories, + workspaceless: readSessionWorkspaceless(state._meta), project: harness.stateManager.getSessionSummary(harness.session.toString())?.project, + isolation: state.config?.values[SessionConfigKey.Isolation], + created: worktrees.createdWorktrees, removed: worktrees.removedWorktrees, retained: worktrees.retainedSessions, + trust: harness.trustRequests, continued: harness.continuations.map(entry => entry.chat), + blocked: harness.service.isPending(harness.chat.toString()), + canIsolate: harness.service.canIsolateChat(harness.chat), + persisted: await harness.database.getMetadata('agentHost.chatIsolationDirectory'), + }, { + calls: [[harness.chat.toString(), workspace.toString()]], + transition: serializeAgentWorkspaceTransition({ + content: 'Workspace changed to destination', + workspaceKind: AgentSystemNotificationWorkspaceKind.Folder, + workspaceName: 'destination', + }), + sessionDirectory: [workspace.toString()], chatDirectory: [workspace.toString()], + workspaceless: false, project: { uri: workspace.toString(), displayName: 'destination' }, isolation: 'folder', + created: [], removed: [], retained: [harness.session.toString()], + trust: [{ clientId: 'client', workspace: workspace.toString() }], continued: [harness.chat.toString()], blocked: false, canIsolate: true, persisted: '', + }); + await harness.service.restoreChatIsolation(harness.chat.toString()); + assert.strictEqual(harness.service.canIsolateChat(harness.chat), true); + }); + } + + test('synchronizes removed branch configuration with clients during a folder switch', async () => { + const harness = createHarness(new TestWorktreeIsolation(URI.file('/worktree'))); + makeFolderSession(harness); + setSessionConfig(harness, { + [SessionConfigKey.Isolation]: 'worktree', + [SessionConfigKey.Branch]: 'release-A', + [SessionConfigKey.AutoApprove]: 'assisted', + [SessionConfigKey.WorktreeIncludeFiles]: ['.env'], + }); + let client: SessionState = structuredClone(harness.stateManager.getSessionState(harness.session.toString())!); + disposables.add(harness.stateManager.onDidEmitEnvelope(envelope => { + if (envelope.channel === harness.session.toString() && envelope.action.type === ActionType.SessionConfigChanged) { + client = sessionReducer(client, envelope.action); + } + })); + await changeWorkspaceViaTool(harness, harness.chat, URI.file('/workspace/destination')); + const expected = { + [SessionConfigKey.Isolation]: 'folder', + [SessionConfigKey.AutoApprove]: 'assisted', + [SessionConfigKey.WorktreeIncludeFiles]: ['.env'], + }; + assert.deepStrictEqual({ + host: harness.stateManager.getSessionState(harness.session.toString())?.config?.values, + client: client.config?.values, + persisted: JSON.parse((await harness.database.getMetadata('configValues'))!), + }, { host: expected, client: expected, persisted: expected }); + }); + + for (const target of ['main', 'peer'] as const) { + test(`workspace tool changes only the ${target} chat's workspace while its sibling is running`, async () => { + const workspace = URI.file('/workspace/destination'); + const prepared: string[] = []; + const host: IChatIsolationHost = { + runWithChatCatalogLock: (_session, operation) => operation(), + prepareChatWorkingDirectory: async (_session, directory, options) => { + prepared.push(options.isolation); + return { directory, release: async () => assert.fail('Selected folder must not be deleted') }; + }, + setChatWorkingDirectory: async (session, chat, directory, replace) => { + assert.strictEqual(replace, false); + harness.stateManager.dispatchServerAction(session.toString(), { + type: ActionType.SessionChatUpdated, chat: chat.toString(), changes: { workingDirectories: [directory.toString()] }, + }); + }, + }; + const harness = createHarness(new NullAgentHostWorktreeIsolation(), async () => true, new TestSessionDatabase(), 'copilot', host); + makeFolderSession(harness); + harness.agent.setWorkingDirectory = async () => assert.fail('Must not reanchor shared configuration'); + const provider: IAgent = harness.agent; + provider.setChatWorkingDirectory = async (_chat, _context, _directory, options) => assert.strictEqual(options, undefined); + const peer = URI.parse(buildChatUri(harness.session, 'peer')); + harness.stateManager.addChat(harness.session.toString(), peer.toString(), { workingDirectories: [harness.scratch.toString()] }); + const chat = target === 'main' ? harness.chat : peer; + const sibling = target === 'main' ? peer : harness.chat; + startTurn(harness.stateManager, sibling, 'sibling-turn'); + await changeWorkspaceViaTool(harness, chat, workspace); + assert.deepStrictEqual({ + prepared, session: harness.stateManager.getSessionSummary(harness.session.toString())?.workingDirectories, + target: harness.stateManager.getSessionState(harness.session.toString())?.chats.find(candidate => candidate.resource === chat.toString())?.workingDirectories, + sibling: harness.stateManager.getActiveTurnId(sibling.toString()), continued: harness.continuations.map(entry => entry.chat), + }, { prepared: ['folder'], session: [harness.scratch.toString()], target: [workspace.toString()], sibling: 'sibling-turn', continued: [chat.toString()] }); + }); + } + + for (const siblingRestore of ['restores', 'fails'] as const) { + test(`workspace tool ${siblingRestore === 'restores' ? 'restores lazily registered siblings before the provider moves' : 'fails cleanly without quarantine when a sibling cannot be restored'}`, async () => { + const workspace = URI.file('/workspace/selected'); + const events: string[] = []; + const host: IChatIsolationHost = { + runWithChatCatalogLock: (_session, operation) => operation(), + prepareChatWorkingDirectory: async (_session, directory) => ({ directory, release: async () => { } }), + setChatWorkingDirectory: async (session, chat, directory) => { + harness.stateManager.dispatchServerAction(session.toString(), { + type: ActionType.SessionChatUpdated, chat: chat.toString(), changes: { workingDirectories: [directory.toString()] }, + }); + }, + }; + const harness = createHarness(new NullAgentHostWorktreeIsolation(), async () => true, new TestSessionDatabase(), 'copilot', host); + makeFolderSession(harness); + const provider: IAgent = harness.agent; + provider.setChatWorkingDirectory = async chat => { events.push(`move:${chat.toString()}`); }; + const peer = URI.parse(buildChatUri(harness.session, 'peer')); + harness.stateManager.registerRestoredChatSummary(harness.session.toString(), peer.toString(), { + resolver: async () => { + events.push(`restore:${peer.toString()}`); + if (siblingRestore === 'fails') { + throw new Error('peer backing is unavailable'); + } + return { turns: [] }; + }, + }); + const subagentChat = URI.parse(buildChatUri(harness.session, 'subagent')); + harness.stateManager.registerRestoredChatSummary(harness.session.toString(), subagentChat.toString(), { + origin: { kind: ChatOriginKind.Tool, chat: harness.chat.toString(), toolCallId: 'tool' }, + resolver: async () => { + events.push(`restore:${subagentChat.toString()}`); + throw new Error('subagent transcript is unavailable'); + }, + }); + await changeWorkspaceViaTool(harness, harness.chat, workspace); + assert.deepStrictEqual({ + events, + directories: harness.stateManager.getSessionState(harness.session.toString())?.chats.find(candidate => candidate.resource === harness.chat.toString())?.workingDirectories, + blocked: harness.service.isPending(harness.chat.toString()), + failureReported: /peer backing is unavailable/.test(harness.continuations[0]?.message.text ?? ''), + failedTurns: harness.failedContinuations.length, + }, siblingRestore === 'restores' + ? { events: [`restore:${peer.toString()}`, `move:${harness.chat.toString()}`], directories: [workspace.toString()], blocked: false, failureReported: false, failedTurns: 0 } + : { events: [`restore:${peer.toString()}`], directories: undefined, blocked: false, failureReported: true, failedTurns: 0 }); + }); + } + + for (const failure of ['trust', 'provider', 'uncertain'] as const) { + test(`workspace tool preserves the source on ${failure} failure and quarantines only uncertain changes after a restart`, async () => { + const worktrees = new TestWorktreeIsolation(URI.file('/old-worktree')); + const harness = createHarness(worktrees, async () => failure !== 'trust'); + makeFolderSession(harness); + const provider: IAgent = harness.agent; + provider.setChatWorkingDirectory = async () => { + throw failure === 'uncertain' ? new AgentWorkingDirectoryChangedError(URI.file('/destination'), 'uncertain') : new Error('provider rejected'); + }; + await changeWorkspaceViaTool(harness, harness.chat, URI.file('/destination')); + await harness.service.restoreChatIsolation(harness.chat.toString()); + assert.deepStrictEqual({ + directories: harness.stateManager.getSessionState(harness.session.toString())?.workingDirectories, + blocked: harness.service.isPending(harness.chat.toString()), retained: worktrees.retainedSessions, + removed: worktrees.removedWorktrees, continued: harness.continuations.map(entry => entry.chat), + failed: harness.failedContinuations.map(entry => entry.chat), + }, { + directories: [harness.scratch.toString()], blocked: failure === 'uncertain', retained: [], removed: [], + continued: failure === 'uncertain' ? [] : [harness.chat.toString()], + failed: failure === 'uncertain' ? [harness.chat.toString()] : [], + }); + }); + } + + test('a spawned subagent chat does not make a single-chat workspace change preserve the old folder', async () => { + const harness = createHarness(); + makeFolderSession(harness); + harness.stateManager.addChat(harness.session.toString(), buildChatUri(harness.session, 'subagent'), { + origin: { kind: ChatOriginKind.Tool, chat: harness.chat.toString(), toolCallId: 'tool' }, + interactivity: ChatInteractivity.ReadOnly, + }); + const destination = URI.file('/workspace/destination'); + const provider: IAgent = harness.agent; + const replaceFlags: (boolean | undefined)[] = []; + provider.setChatWorkingDirectory = async (_chat, _context, _directory, options) => { replaceFlags.push(options?.replaceSessionWorkspace); }; + await changeWorkspaceViaTool(harness, harness.chat, destination); + assert.deepStrictEqual({ + replaceFlags, + sessionRoots: harness.stateManager.getSessionSummary(harness.session.toString())?.workingDirectories, + }, { replaceFlags: [true], sessionRoots: [destination.toString()] }); + }); + + for (const restriction of ['readOnly', 'worker', 'archived', 'chatArchived', 'quarantined'] as const) { + test(`rejects requesting isolation of ${restriction} chat`, async () => { + const isolation = new TestWorktreeIsolation(URI.file('/worktree')); + const harness = createHarness(isolation); + makeFolderSession(harness); + const peer = URI.parse(buildChatUri(harness.session, 'peer')); + harness.stateManager.addChat(harness.session.toString(), peer.toString(), { + workingDirectories: [harness.scratch.toString()], + interactivity: restriction === 'readOnly' ? ChatInteractivity.ReadOnly : undefined, + origin: restriction === 'worker' ? { kind: ChatOriginKind.Tool, chat: harness.chat.toString(), toolCallId: 'tool' } : undefined, + }); + if (restriction === 'archived') { + harness.stateManager.dispatchServerAction(harness.session.toString(), { type: ActionType.SessionIsArchivedChanged, isArchived: true }); + } else if (restriction === 'chatArchived') { + harness.stateManager.dispatchServerAction(peer.toString(), { type: ActionType.ChatIsArchivedChanged, isArchived: true }); + } else if (restriction === 'quarantined') { + await harness.database.setMetadata('agentHost.chatIsolationQuarantined', 'true'); + await harness.service.restoreChatIsolation(peer.toString()); + } + startTurn(harness.stateManager, peer); + assert.throws(() => harness.service.requestChatIsolation(peer, 'turn-1', 'client'), /cannot be changed to a new worktree/); + assert.deepStrictEqual({ created: isolation.createdWorktrees, begin: harness.deferredContinuations, continue: harness.continuations }, { + created: [], begin: [], continue: [], + }); + }); + } + + for (const quarantined of [false, true]) { + test(`restoring chat isolation blocks the chat only for a durable failure (quarantined: ${quarantined})`, async () => { + const harness = createHarness(new TestWorktreeIsolation(URI.file('/worktree'))); + makeFolderSession(harness); + if (quarantined) { + await harness.database.setMetadata('agentHost.chatIsolationQuarantined', 'true'); + } + await harness.service.restoreChatIsolation(harness.chat.toString()); + assert.strictEqual(harness.service.isPending(harness.chat.toString()), quarantined); + }); + } + + test('clears restored isolation and quarantine when a peer is removed and recreated', async () => { + const harness = createHarness(new TestWorktreeIsolation(URI.file('/worktree'))); + makeFolderSession(harness); + const peer = URI.parse(buildChatUri(harness.session, 'peer')); + harness.stateManager.addChat(harness.session.toString(), peer.toString(), { workingDirectories: [harness.scratch.toString()] }); + await harness.database.setMetadata('agentHost.chatIsolationDirectory', URI.file('/worktree').toString()); + await harness.database.setMetadata('agentHost.chatIsolationQuarantined', 'true'); + await harness.service.restoreChatIsolation(peer.toString()); + harness.stateManager.removeChat(harness.session.toString(), peer.toString()); + const pendingAfterRemoval = harness.service.isPending(peer.toString()); + await harness.database.deleteMetadata(['agentHost.chatIsolationDirectory', 'agentHost.chatIsolationQuarantined']); + harness.stateManager.addChat(harness.session.toString(), peer.toString(), { workingDirectories: [harness.scratch.toString()] }); + const isolatableBeforeRestore = harness.service.canIsolateChat(peer); + await harness.service.restoreChatIsolation(peer.toString()); + assert.deepStrictEqual({ + pendingAfterRemoval, + isolatableBeforeRestore, + pending: harness.service.isPending(peer.toString()), + isolatable: harness.service.canIsolateChat(peer), + }, { pendingAfterRemoval: false, isolatableBeforeRestore: true, pending: false, isolatable: true }); + }); + + test('ignores a stale isolation restore after peer deletion and recreation', async () => { + const readStarted = new DeferredPromise(); + const releaseRead = new DeferredPromise(); + let gate = true; + const database = new class extends TestSessionDatabase { + override async getMetadata(key: string): Promise { + if (gate && key === 'agentHost.chatIsolationDirectory') { + gate = false; + readStarted.complete(); + await releaseRead.p; + return URI.file('/old-worktree').toString(); + } + return super.getMetadata(key); + } + }; + const harness = createHarness(new TestWorktreeIsolation(URI.file('/worktree')), async () => true, database); + makeFolderSession(harness); + const peer = URI.parse(buildChatUri(harness.session, 'peer')); + harness.stateManager.addChat(harness.session.toString(), peer.toString(), { workingDirectories: [harness.scratch.toString()] }); + const restore = harness.service.restoreChatIsolation(peer.toString()); + await readStarted.p; + harness.stateManager.removeChat(harness.session.toString(), peer.toString()); + harness.stateManager.addChat(harness.session.toString(), peer.toString(), { workingDirectories: [harness.scratch.toString()] }); + await harness.service.restoreChatIsolation(peer.toString()); + releaseRead.complete(); + await restore; + assert.deepStrictEqual({ + pending: harness.service.isPending(peer.toString()), + isolatable: harness.service.canIsolateChat(peer), + }, { pending: false, isolatable: true }); + }); + + test('does not quarantine a recreated peer when its deleted workspace conversion finishes', async () => { + const providerStarted = new DeferredPromise(); + const releaseProvider = new DeferredPromise(); + const host: IChatIsolationHost = { + runWithChatCatalogLock: (_session, operation) => operation(), + prepareChatWorkingDirectory: async () => ({ directory: URI.file('/worktree'), release: async () => { } }), + setChatWorkingDirectory: async () => assert.fail('Must not update a deleted chat'), + }; + const harness = createHarness(new TestWorktreeIsolation(URI.file('/worktree')), async () => true, new TestSessionDatabase(), 'copilot', host); + makeFolderSession(harness); + const peer = URI.parse(buildChatUri(harness.session, 'peer')); + harness.stateManager.addChat(harness.session.toString(), peer.toString(), { workingDirectories: [harness.scratch.toString()] }); + const provider: IAgent = harness.agent; + provider.setChatWorkingDirectory = async () => { + providerStarted.complete(); + await releaseProvider.p; + }; + const conversion = changeWorkspaceViaTool(harness, peer, undefined); + await providerStarted.p; + harness.stateManager.removeChat(harness.session.toString(), peer.toString()); + await harness.database.deleteMetadata(['agentHost.chatIsolationQuarantined']); + harness.stateManager.addChat(harness.session.toString(), peer.toString(), { workingDirectories: [harness.scratch.toString()] }); + await harness.service.restoreChatIsolation(peer.toString()); + harness.stateManager.dispatchServerAction(peer.toString(), { type: ActionType.ChatActivityChanged, activity: 'Replacement chat activity' }); + releaseProvider.complete(); + await conversion; + assert.deepStrictEqual({ + pending: harness.service.isPending(peer.toString()), + isolatable: harness.service.canIsolateChat(peer), + activity: harness.stateManager.getChatState(peer.toString())?.activity, + quarantine: await harness.database.getMetadata('agentHost.chatIsolationQuarantined'), + }, { pending: false, isolatable: true, activity: 'Replacement chat activity', quarantine: undefined }); + }); + + test('isolates a single-chat session without multi-root, preserving its project and history', async () => { + const worktree = URI.file('/workspace/project.worktrees/feature'); + const isolation = new TestWorktreeIsolation(worktree); + const harness = createHarness(isolation); + makeFolderSession(harness); + completePriorTurn(harness.stateManager, harness.chat); + harness.agent.getDescriptor = () => ({ provider: 'copilot', displayName: 'Copilot', description: '' }); + startTurn(harness.stateManager, harness.chat); + const providerCalls: string[] = []; + const provider: IAgent = harness.agent; + provider.setChatWorkingDirectory = async (_chat, _context, directory) => { providerCalls.push(directory.toString()); }; + assert.strictEqual(harness.service.canIsolateChat(harness.chat), true); + harness.service.requestChatIsolation(harness.chat, 'turn-1', 'client-1'); + assert.strictEqual(harness.service.isPending(harness.chat.toString(), true), true); + completeTurn(harness.stateManager, harness.chat); + await updateSessionWorkspace(harness); + const state = harness.stateManager.getSessionState(harness.session.toString())!; + assert.deepStrictEqual({ + directories: harness.stateManager.getSessionSummary(harness.session.toString())?.workingDirectories, + chatDirectories: state.workingDirectories, + project: harness.stateManager.getSessionSummary(harness.session.toString())?.project?.uri, + trustRequests: harness.trustRequests, + isolation: state.config?.values[SessionConfigKey.Isolation], + approval: state.config?.values[SessionConfigKey.AutoApprove], + persistedConfig: JSON.parse((await harness.database.getMetadata('configValues'))!), + mainTurnIds: harness.stateManager.getChatState(harness.chat.toString())!.turns.map(turn => turn.id), + continuations: harness.continuations.map(entry => entry.chat), + transition: harness.stateManager.getChatState(harness.chat.toString())?.activeTurn?.responseParts + .filter(part => part.kind === ResponsePartKind.SystemNotification).map(part => part.content), + providerCalls, + gitRefreshes: harness.gitRefreshes, + worktreeConfig: isolation.requests[0]?.config, + pending: harness.service.isPending(harness.chat.toString()), + }, { + directories: [worktree.toString()], + chatDirectories: [worktree.toString()], + project: isolation.repository.toString(), + trustRequests: [ + { clientId: 'client-1', workspace: harness.scratch.toString() }, + { clientId: 'client-1', workspace: isolation.repository.toString() }, + { clientId: 'client-1', workspace: worktree.toString(), trustedParent: isolation.repository.toString() }, + ], + isolation: 'worktree', + approval: 'default', + persistedConfig: { + isolation: 'worktree', autoApprove: 'default', worktreeIncludeFiles: ['.env'], branch: 'main', + }, + mainTurnIds: ['turn-0', 'turn-1'], + continuations: [harness.chat.toString()], + transition: ['Workspace changed to a new worktree of workspace-less'], + providerCalls: [worktree.toString()], + gitRefreshes: [], + worktreeConfig: { + isolation: 'worktree', autoApprove: 'default', worktreeIncludeFiles: ['.env'], branch: 'main', + }, + pending: false, + }); + }); + + for (const target of ['single', 'main', 'peer'] as const) { + test(`registers isolation for the ${target} chat before readiness but rejects premature execution`, async () => { + const harness = createHarness(new TestWorktreeIsolation(URI.file('/worktree'))); + const { stateManager, service, session, agent } = harness; + const peer = URI.parse(buildChatUri(session, 'peer')); + if (target === 'single') { + agent.getDescriptor = () => ({ provider: agent.id, displayName: 'Agent', description: '' }); + } else { + stateManager.addChat(session.toString(), peer.toString(), { title: 'Peer' }); + } + stateManager.setSessionMeta(session.toString(), withSessionWorkspaceless(undefined, false)); + const provider: IAgent = agent; + provider.setChatWorkingDirectory = async () => { }; + setSessionConfig(harness, { [SessionConfigKey.Isolation]: 'folder' }); + const host = new AgentServerToolHost(stateManager, [createSessionIsolationToolGroup({ + supportsChatIsolation: resource => service.supportsChatIsolation(resource), + requestChatIsolation: (chat, turnId) => service.requestChatIsolation(chat, turnId, 'client-1'), + })]); + const chat = target === 'peer' ? peer : harness.chat; + const toolsAtInitialization = host.getDefinitionsForSession(session.toString()).map(tool => tool.name); + host.advertise(chat.toString()); + startTurn(stateManager, chat); + assert.throws(() => host.executeTool(chat.toString(), SessionServerToolName.IsolateSession, {}), /workspace cannot be changed to a new worktree/); + const before = { + canIsolate: service.canIsolateChat(chat), + pending: service.isPending(chat.toString()), + }; + stateManager.dispatchServerAction(session.toString(), { type: ActionType.SessionReady }); + host.executeTool(chat.toString(), SessionServerToolName.IsolateSession, {}); + assert.deepStrictEqual({ + toolsAtInitialization, before, + pendingAfterReady: service.isPending(chat.toString()), + }, { + toolsAtInitialization: [SessionServerToolName.IsolateSession], + before: { canIsolate: false, pending: false }, + pendingAfterReady: true, + }); + }); + } + + for (const target of ['main', 'peer'] as const) { + test(`refreshes isolation tools when a restored multi-chat session becomes ready for its ${target} chat`, () => { + const harness = createHarness(new TestWorktreeIsolation(URI.file('/worktree'))); + const { stateManager, session, service } = harness; + const peer = URI.parse(buildChatUri(session, 'peer')); + stateManager.addChat(session.toString(), peer.toString(), { title: 'Peer' }); + stateManager.setSessionMeta(session.toString(), withSessionWorkspaceless(undefined, false)); + const provider: IAgent = harness.agent; + provider.setChatWorkingDirectory = async () => { }; + setSessionConfig(harness, { [SessionConfigKey.Isolation]: 'folder' }); + const host = new AgentServerToolHost(stateManager, [createSessionIsolationToolGroup({ + supportsChatIsolation: resource => service.supportsChatIsolation(resource), + requestChatIsolation: (chat, turnId) => service.requestChatIsolation(chat, turnId, 'client-1'), + })]); + harness.serverToolHost.advertise = resource => host.advertise(resource); + stateManager.dispatchServerAction(session.toString(), { type: ActionType.SessionServerToolsChanged, tools: [] }); + const before = stateManager.getSessionState(session.toString())?.serverTools?.map(tool => tool.name); + stateManager.dispatchServerAction(session.toString(), { type: ActionType.SessionReady }); + const after = stateManager.getSessionState(session.toString())?.serverTools?.map(tool => tool.name); + const chat = target === 'main' ? harness.chat : peer; + startTurn(stateManager, chat); + host.executeTool(chat.toString(), SessionServerToolName.IsolateSession, {}); + assert.deepStrictEqual({ + before, after, requested: service.isPending(chat.toString()), + otherPending: service.isPending((target === 'main' ? peer : harness.chat).toString()), + }, { before: [], after: [SessionServerToolName.IsolateSession], requested: true, otherPending: false }); + }); + } + + test('peer tool advertisement updates the owning session before isolation execution', () => { + const harness = createHarness(new TestWorktreeIsolation(URI.file('/worktree'))); + makeFolderSession(harness); + const { stateManager, service, session } = harness; + const peer = URI.parse(buildChatUri(session, 'peer')); + stateManager.addChat(session.toString(), peer.toString(), { title: 'Peer' }); + stateManager.dispatchServerAction(session.toString(), { type: ActionType.SessionServerToolsChanged, tools: [] }); + const host = new AgentServerToolHost(stateManager, [createSessionIsolationToolGroup({ + supportsChatIsolation: resource => service.supportsChatIsolation(resource), + requestChatIsolation: (chat, turnId) => service.requestChatIsolation(chat, turnId, 'client-1'), + })]); + const advertisedChannels: string[] = []; + disposables.add(stateManager.onDidEmitEnvelope(envelope => { + if (envelope.action.type === ActionType.SessionServerToolsChanged) { + advertisedChannels.push(envelope.channel); + } + })); + host.advertise(peer.toString()); + startTurn(stateManager, peer); + host.executeTool(peer.toString(), SessionServerToolName.IsolateSession, {}); + assert.deepStrictEqual({ + advertisedChannels, + tools: stateManager.getSessionState(session.toString())?.serverTools?.map(tool => tool.name), + pending: service.isPending(peer.toString()), + }, { advertisedChannels: [session.toString()], tools: [SessionServerToolName.IsolateSession], pending: true }); + }); + + test('Codex isolation tool is available without multi-root and continues the same single chat', async () => { + const isolation = new TestWorktreeIsolation(URI.file('/worktree')); + const harness = createHarness(isolation, async () => true, new TestSessionDatabase(), 'codex'); + makeFolderSession(harness); + const host = new AgentServerToolHost(harness.stateManager, [createSessionIsolationToolGroup({ + supportsChatIsolation: resource => harness.service.supportsChatIsolation(resource), + requestChatIsolation: (chat, turnId) => harness.service.requestChatIsolation(chat, turnId, 'client-1'), + })]); + const session = harness.session.toString(); + const main = harness.chat.toString(); + harness.agent.getDescriptor = () => ({ provider: 'codex', displayName: 'Codex', description: '' }); + host.advertise(session); + assert.deepStrictEqual({ + mainTools: host.getDefinitionsForSession(session).map(tool => tool.name), + }, { mainTools: [SessionServerToolName.IsolateSession] }); + const calls: string[] = []; + const provider: IAgent = harness.agent; + provider.setChatWorkingDirectory = async resource => { calls.push(resource.toString()); }; + startTurn(harness.stateManager, harness.chat); + host.executeTool(main, SessionServerToolName.IsolateSession, {}); + completeTurn(harness.stateManager, harness.chat); + await updateSessionWorkspace(harness); + assert.deepStrictEqual({ + calls, + directories: harness.stateManager.getSessionState(session)?.workingDirectories, + continuations: harness.continuations.map(entry => entry.chat), + toolsAfter: host.getDefinitionsForSession(session).map(tool => tool.name), + }, { calls: [main], directories: ['file:///worktree'], continuations: [main], toolsAfter: [SessionServerToolName.IsolateSession] }); + assert.throws(() => host.executeTool(main, SessionServerToolName.IsolateSession, {}), /workspace cannot be changed to a new worktree/); + }); + + test('tool-driven conversion blocks its chat and protects its continuation turn only while converting', async () => { + const harness = createHarness(new TestWorktreeIsolation(URI.file('/workspace/project.worktrees/feature'))); + makeFolderSession(harness); + const entered = new DeferredPromise(); + const release = new DeferredPromise(); + const provider: IAgent = harness.agent; + provider.setChatWorkingDirectory = async () => { + entered.complete(); + await release.p; + }; + const chat = harness.chat.toString(); + const snapshot = () => ({ + pending: harness.service.isPending(chat), + protectedTurn: harness.service.isConversionTurn(chat, 'continuation-1'), + }); + startTurn(harness.stateManager, harness.chat); + harness.service.requestChatIsolation(harness.chat, 'turn-1', 'client-1'); + const requested = snapshot(); + completeTurn(harness.stateManager, harness.chat); + const converting = updateSessionWorkspace(harness); + await entered.p; + const during = snapshot(); + release.complete(); + await converting; + assert.deepStrictEqual({ requested, during, after: snapshot() }, { + requested: { pending: true, protectedTurn: false }, + during: { pending: true, protectedTurn: true }, + after: { pending: false, protectedTurn: false }, + }); + }); + + test('publishes the generated worktree branch before exposing its working directory', async () => { + const worktree = URI.file('/workspace/project.worktrees/feature'); + const harness = createHarness(new TestWorktreeIsolation(worktree)); + makeFolderSession(harness); + harness.stateManager.setSessionMeta(harness.session.toString(), withSessionGitState( + harness.stateManager.getSessionState(harness.session.toString())?._meta, + { branchName: 'main', baseBranchName: 'main' }, + )); + const observed: { directory: string | undefined; branch: string | undefined }[] = []; + disposables.add(harness.stateManager.onDidChangeSessionWorkingDirectories(({ session }) => { + if (session !== harness.session.toString()) { + return; + } + const state = harness.stateManager.getSessionState(session); + observed.push({ directory: state?.workingDirectories?.[0], branch: readSessionGitState(state?._meta)?.branchName }); + })); + startTurn(harness.stateManager, harness.chat); + harness.service.requestChatIsolation(harness.chat, 'turn-1', 'client-1'); + completeTurn(harness.stateManager, harness.chat); + await updateSessionWorkspace(harness); + assert.deepStrictEqual({ + observed, + baseBranch: harness.stateManager.getSessionState(harness.session.toString())?.config?.values[SessionConfigKey.Branch], + }, { + observed: [{ directory: worktree.toString(), branch: 'feature' }], + baseBranch: 'main', + }); + }); + + test('only a catalogued chat with its active client turn may request isolation', () => { + const harness = createHarness(new TestWorktreeIsolation(URI.file('/worktree'))); + makeFolderSession(harness); + startTurn(harness.stateManager, harness.chat); + const peer = URI.parse(buildChatUri(harness.session, 'peer')); + assert.throws(() => harness.service.requestChatIsolation(peer, 'peer-turn', 'client-1'), /active turn/); + assert.throws(() => harness.service.requestChatIsolation(harness.chat, 'wrong-turn', 'client-1'), /active turn/); + assert.throws(() => harness.service.requestChatIsolation(harness.chat, 'turn-1', ''), /connected client/); + }); + + test('a peer turn finishing before the requesting turn does not start isolation', async () => { + const isolation = new TestWorktreeIsolation(URI.file('/worktree')); + const harness = createHarness(isolation); + makeFolderSession(harness); + startTurn(harness.stateManager, harness.chat); + harness.service.requestChatIsolation(harness.chat, 'turn-1', 'client-1'); + await harness.service.updateSessionWorkspace(buildChatUri(harness.session, 'peer'), 'peer-turn'); + assert.strictEqual(isolation.createdWorktrees.length, 0); + completeTurn(harness.stateManager, harness.chat); + await updateSessionWorkspace(harness); + assert.deepStrictEqual(isolation.createdWorktrees.map(uri => uri.toString()), ['file:///worktree']); + }); + + test('does not create a worktree if the folder session is archived while awaiting trust', async () => { + const trust = new DeferredPromise(); + const isolation = new TestWorktreeIsolation(URI.file('/worktree')); + const harness = createHarness(isolation, () => trust.p); + makeFolderSession(harness); + startTurn(harness.stateManager, harness.chat); + harness.service.requestChatIsolation(harness.chat, 'turn-1', 'client-1'); + completeTurn(harness.stateManager, harness.chat); + const conversion = updateSessionWorkspace(harness); + harness.stateManager.dispatchServerAction(harness.session.toString(), { type: ActionType.SessionIsArchivedChanged, isArchived: true }); + trust.complete(true); + await conversion; + assert.deepStrictEqual(isolation.createdWorktrees, []); + assert.match(harness.continuations[0].message.text, /chat changed/); + }); + + for (const target of ['single', 'main', 'peer'] as const) { + for (const isolation of [false, true]) { + test(`workspace tool changes existing ${target} chat to ${isolation ? 'new worktree' : 'folder'} after its turn`, async () => { + const destination = URI.file('/workspace/destination'); + const worktree = URI.file('/workspace/destination.worktrees/task'); + const expectedDirectory = isolation ? worktree : destination; + const host: IChatIsolationHost = { + runWithChatCatalogLock: (_session, operation) => operation(), + prepareChatWorkingDirectory: async (_session, directory, options) => { + assert.deepStrictEqual([directory.toString(), options.isolation], [destination.toString(), isolation ? 'worktree' : 'folder']); + return { directory: expectedDirectory, release: async () => assert.fail('Must retain successful destination') }; + }, + setChatWorkingDirectory: async (session, chat, directory) => { + harness.stateManager.dispatchServerAction(session.toString(), { + type: ActionType.SessionChatUpdated, chat: chat.toString(), changes: { workingDirectories: [directory.toString()] }, + }); + }, + }; + const harness = createHarness(new TestWorktreeIsolation(worktree), async () => true, new TestSessionDatabase(), 'copilot', target === 'single' ? undefined : host); + makeFolderSession(harness); + const peer = URI.parse(buildChatUri(harness.session, 'peer')); + if (target === 'single') { + harness.agent.getDescriptor = () => ({ provider: 'copilot', displayName: 'Copilot', description: '' }); + } else { + harness.stateManager.addChat(harness.session.toString(), peer.toString(), { workingDirectories: [harness.scratch.toString()] }); + } + const chat = target === 'peer' ? peer : harness.chat; + const sibling = target === 'main' ? peer : harness.chat; + if (target !== 'single') { + startTurn(harness.stateManager, sibling, 'sibling-turn'); + } + const calls: { chat: string; directory: string; replaceSessionWorkspace: boolean | undefined }[] = []; + let customizationRefreshes = 0; + const provider: IAgent = harness.agent; + provider.setChatWorkingDirectory = async (resource, _context, directory, options) => { + calls.push({ chat: resource.toString(), directory: directory.toString(), replaceSessionWorkspace: options?.replaceSessionWorkspace }); + }; + provider.getChatCustomizations = async () => { + customizationRefreshes++; + return []; + }; + startTurn(harness.stateManager, chat); + harness.service.requestSessionWorkspaceUpdate(chat, 'turn-1', destination, isolation, 'client-1'); + assert.deepStrictEqual({ calls, pending: harness.service.isPending(chat.toString()) }, { calls: [], pending: true }); + completeTurn(harness.stateManager, chat); + await harness.service.updateSessionWorkspace(chat.toString(), 'turn-1'); + const state = harness.stateManager.getSessionState(harness.session.toString())!; + assert.deepStrictEqual({ + calls, + customizationRefreshes, + directory: state.chats.find(candidate => candidate.resource === chat.toString())?.workingDirectories, + sessionDirectory: harness.stateManager.getSessionSummary(harness.session.toString())?.workingDirectories, + siblingDirectory: target === 'single' ? undefined : state.chats.find(candidate => candidate.resource === sibling.toString())?.workingDirectories ?? harness.stateManager.getSessionSummary(harness.session.toString())?.workingDirectories, + siblingTurn: target === 'single' ? undefined : harness.stateManager.getActiveTurnId(sibling.toString()), + continuations: harness.continuations.map(entry => entry.chat), + transition: (await harness.database.getTurnWorkspaceTransitions()).get('continuation-1'), + pending: harness.service.isPending(chat.toString()), + }, { + calls: [{ chat: chat.toString(), directory: expectedDirectory.toString(), replaceSessionWorkspace: target === 'single' ? true : undefined }], + customizationRefreshes: target === 'single' ? 1 : 0, + directory: [expectedDirectory.toString()], + sessionDirectory: [target === 'single' ? expectedDirectory.toString() : harness.scratch.toString()], + siblingDirectory: target === 'single' ? undefined : [harness.scratch.toString()], + siblingTurn: target === 'single' ? undefined : 'sibling-turn', + continuations: [chat.toString()], + transition: serializeAgentWorkspaceTransition({ + content: isolation ? 'Workspace changed to a new worktree of destination' : 'Workspace changed to destination', + workspaceKind: isolation ? AgentSystemNotificationWorkspaceKind.Worktree : AgentSystemNotificationWorkspaceKind.Folder, + workspaceName: 'destination', + }), + pending: false, + }); + assert.match(harness.continuations[0].message.text, isolation ? /Only this chat now uses the worktree/ : /Only this chat now uses the workspace/); + }); + } + } + + for (const target of ['single', 'main', 'peer', 'worktree'] as const) { + test(`workspace tool uses host path casing for the ${target} chat's no-op detection`, () => { + const harness = createHarness(); + makeFolderSession(harness); + const peer = URI.parse(buildChatUri(harness.session, 'peer')); + const peerFolder = URI.file('/workspace/peer'); + if (target === 'main' || target === 'peer') { + harness.stateManager.addChat(harness.session.toString(), peer.toString(), { workingDirectories: [peerFolder.toString()] }); + } + if (target === 'worktree') { + setSessionConfig(harness, { [SessionConfigKey.Isolation]: 'worktree' }); + } + const chat = target === 'peer' ? peer : harness.chat; + const directory = target === 'peer' ? peerFolder : harness.scratch; + startTurn(harness.stateManager, chat); + const scheduled = harness.service.requestSessionWorkspaceUpdate(chat, 'turn-1', directory.with({ path: directory.path.toUpperCase() }), false, 'client-1'); + assert.deepStrictEqual({ + scheduled, + pending: harness.service.isPending(chat.toString()), + trust: harness.trustRequests, + deferred: harness.deferredContinuations, + }, { scheduled: isLinux, pending: isLinux, trust: [], deferred: [] }); + harness.service.cancel(chat.toString(), 'turn-1'); + }); + + test(`workspace tool skips the current ${target} chat directory without scheduling a transition`, async () => { + const harness = createHarness(); + makeFolderSession(harness); + const peer = URI.parse(buildChatUri(harness.session, 'peer')); + const peerFolder = URI.file('/workspace/peer'); + if (target === 'main' || target === 'peer') { + harness.stateManager.addChat(harness.session.toString(), peer.toString(), { workingDirectories: [peerFolder.toString()] }); + } + if (target === 'worktree') { + setSessionConfig(harness, { [SessionConfigKey.Isolation]: 'worktree' }); + } + const chat = target === 'peer' ? peer : harness.chat; + const directory = target === 'peer' ? peerFolder : harness.scratch; + const provider: IAgent = harness.agent; + provider.setChatWorkingDirectory = async () => assert.fail('Unchanged workspace must not reach the provider'); + startTurn(harness.stateManager, chat); + const before = harness.stateManager.getSessionState(harness.session.toString()); + const scheduled = harness.service.requestSessionWorkspaceUpdate(chat, 'turn-1', directory, false, 'client-1'); + const unchanged = harness.stateManager.getSessionState(harness.session.toString()); + completeTurn(harness.stateManager, chat); + await harness.service.updateSessionWorkspace(chat.toString(), 'turn-1'); + assert.deepStrictEqual({ + scheduled, unchanged, + pending: harness.service.isPending(chat.toString()), + trust: harness.trustRequests, + deferred: harness.deferredContinuations, + continued: harness.continuations, + transitions: [...await harness.database.getTurnWorkspaceTransitions()], + notices: harness.stateManager.getChatState(chat.toString())?.turns.flatMap(turn => turn.responseParts), + }, { + scheduled: false, unchanged: before, pending: false, + trust: [], deferred: [], continued: [], transitions: [], notices: [], + }); + }); + } + + test('workspace tool still creates a worktree from the current folder', async () => { + const worktree = URI.file('/workspace/project.worktrees/task'); + const harness = createHarness(new TestWorktreeIsolation(worktree)); + makeFolderSession(harness); + startTurn(harness.stateManager, harness.chat); + const scheduled = harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', harness.scratch, true, 'client-1'); + completeTurn(harness.stateManager, harness.chat); + await updateSessionWorkspace(harness); + assert.deepStrictEqual({ + scheduled, + directories: harness.stateManager.getSessionSummary(harness.session.toString())?.workingDirectories, + continuations: harness.continuations.length, + }, { scheduled: true, directories: [worktree.toString()], continuations: 1 }); + }); + + test('workspace tool still attaches a quick chat when the requested folder matches its scratch directory', () => { + const harness = createHarness(); + startTurn(harness.stateManager, harness.chat); + assert.deepStrictEqual({ + scheduled: harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', harness.scratch, false, 'client-1'), + pending: harness.service.isPending(harness.chat.toString()), + }, { scheduled: true, pending: true }); + }); + + test('workspace tool can leave an existing worktree and retains its ownership', async () => { + const worktrees = new TestWorktreeIsolation(URI.file('/old-worktree')); + const harness = createHarness(worktrees); + makeFolderSession(harness); + setSessionConfig(harness, { [SessionConfigKey.Isolation]: 'worktree' }); + const provider: IAgent = harness.agent; + provider.setChatWorkingDirectory = async () => { }; + startTurn(harness.stateManager, harness.chat); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/destination'), false, 'client-1'); + completeTurn(harness.stateManager, harness.chat); + await updateSessionWorkspace(harness); + assert.deepStrictEqual({ + retained: worktrees.retainedSessions, removed: worktrees.removedWorktrees, + directory: harness.stateManager.getSessionSummary(harness.session.toString())?.workingDirectories, + }, { retained: [harness.session.toString()], removed: [], directory: [URI.file('/destination').toString()] }); + }); + + test('workspace tool moves a chat in a session worktree to a new worktree of another folder and retains the old one', async () => { + const worktrees = new TestWorktreeIsolation(URI.file('/new-worktree')); + const harness = createHarness(worktrees); + makeFolderSession(harness); + setSessionConfig(harness, { [SessionConfigKey.Isolation]: 'worktree' }); + const provider: IAgent = harness.agent; + provider.setChatWorkingDirectory = async () => { }; + startTurn(harness.stateManager, harness.chat); + const current = URI.parse(harness.stateManager.getSessionSummary(harness.session.toString())!.workingDirectories![0]); + const rejectedSameFolder = (() => { + try { + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', current, true, 'client-1'); + return undefined; + } catch (error) { + return (error as Error).message; + } + })(); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/destination'), true, 'client-1'); + completeTurn(harness.stateManager, harness.chat); + await updateSessionWorkspace(harness); + assert.deepStrictEqual({ + rejectedSameFolder, + retained: worktrees.retainedSessions, removed: worktrees.removedWorktrees, + directory: harness.stateManager.getSessionSummary(harness.session.toString())?.workingDirectories, + }, { + rejectedSameFolder: `This chat's workspace cannot be changed to a new worktree of ${current.fsPath}. The chat already uses a worktree of it, or worktrees are not available for it.`, + retained: [harness.session.toString()], removed: [], + directory: [URI.file('/new-worktree').toString()], + }); + }); + + test('workspace tool revalidates existing chats and cancels without changing workspace', async () => { + const harness = createHarness(); + makeFolderSession(harness); + const provider: IAgent = harness.agent; + provider.setChatWorkingDirectory = async () => assert.fail('Cancelled changes must not reach the provider'); + const destination = URI.file('/destination'); + startTurn(harness.stateManager, harness.chat); + assert.throws(() => harness.service.requestSessionWorkspaceUpdate(harness.chat, 'wrong-turn', destination, false, 'client-1'), /active turn/); + assert.throws(() => harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.parse('https://example.com/project'), false, 'client-1'), /existing folder/); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', destination, false, 'client-1'); + assert.throws(() => harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', destination, false, 'client-1'), /cannot be changed/); + harness.service.cancel(harness.chat.toString(), 'turn-1'); + completeTurn(harness.stateManager, harness.chat); + await updateSessionWorkspace(harness); + harness.stateManager.dispatchServerAction(harness.session.toString(), { type: ActionType.SessionIsArchivedChanged, isArchived: true }); + assert.throws(() => harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', destination, false, 'client-1'), /cannot be changed/); + assert.deepStrictEqual({ + pending: harness.service.isPending(harness.chat.toString()), + directory: harness.stateManager.getSessionSummary(harness.session.toString())?.workingDirectories, + continuations: harness.continuations, + }, { pending: false, directory: [harness.scratch.toString()], continuations: [] }); + }); + + for (const failure of ['trust', 'provider', 'uncertain'] as const) { + test(`workspace tool handles ${failure} failure when changing an existing folder`, async () => { + const harness = createHarness(undefined, async () => failure !== 'trust'); + makeFolderSession(harness); + const provider: IAgent = harness.agent; + provider.setChatWorkingDirectory = async () => { + throw failure === 'uncertain' ? new AgentWorkingDirectoryChangedError(URI.file('/destination'), 'uncertain') : new Error('provider rejected'); + }; + startTurn(harness.stateManager, harness.chat); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/destination'), false, 'client-1'); + completeTurn(harness.stateManager, harness.chat); + await updateSessionWorkspace(harness); + assert.deepStrictEqual({ + directory: harness.stateManager.getSessionSummary(harness.session.toString())?.workingDirectories, + blocked: harness.service.isPending(harness.chat.toString()), + failedContinuations: harness.failedContinuations.length, + failureReported: harness.continuations[0]?.message.text.includes('workspace setup did not complete successfully'), + }, { + directory: [harness.scratch.toString()], blocked: failure === 'uncertain', + failedContinuations: failure === 'uncertain' ? 1 : 0, failureReported: failure === 'uncertain' ? undefined : true, + }); + }); + } + + test('does not offer session isolation for quick chats or already isolated sessions', () => { + const harness = createHarness(new TestWorktreeIsolation(URI.file('/worktree'))); + assert.strictEqual(harness.service.canIsolateChat(harness.chat), false); + makeFolderSession(harness); + setSessionConfig(harness, { isolation: 'worktree' }); + assert.strictEqual(harness.service.canIsolateChat(harness.chat), false); + }); + + test('keeps the folder session unchanged when workspace trust is denied', async () => { + const isolation = new TestWorktreeIsolation(URI.file('/worktree')); + const harness = createHarness(isolation, async () => false); + makeFolderSession(harness); + startTurn(harness.stateManager, harness.chat); + harness.service.requestChatIsolation(harness.chat, 'turn-1', 'client-1'); + completeTurn(harness.stateManager, harness.chat); + await updateSessionWorkspace(harness); + assert.match(harness.continuations[0].message.text, /Workspace trust/); + assert.deepStrictEqual({ + directories: harness.stateManager.getSessionState(harness.session.toString())?.workingDirectories, + isolation: harness.stateManager.getSessionState(harness.session.toString())?.config?.values.isolation, + created: isolation.createdWorktrees, + pending: harness.service.isPending(harness.chat.toString()), + }, { + directories: [harness.scratch.toString()], isolation: 'folder', created: [], pending: false, + }); + }); + + test('cleans up a new worktree when folder conversion fails and allows retry', async () => { + const isolation = new TestWorktreeIsolation(URI.file('/worktree')); + const harness = createHarness(isolation); + makeFolderSession(harness); + const provider: IAgent = harness.agent; + provider.setChatWorkingDirectory = async () => { throw new Error('provider mutation failed'); }; + startTurn(harness.stateManager, harness.chat); + harness.service.requestChatIsolation(harness.chat, 'turn-1', 'client-1'); + completeTurn(harness.stateManager, harness.chat); + await updateSessionWorkspace(harness); + assert.match(harness.continuations[0].message.text, /provider mutation failed/); + assert.deepStrictEqual({ + removed: isolation.removedWorktrees.map(entry => entry.worktree.toString()), + isolation: harness.stateManager.getSessionState(harness.session.toString())?.config?.values.isolation, + pending: harness.service.isPending(harness.chat.toString()), + }, { removed: ['file:///worktree'], isolation: 'folder', pending: false }); + }); + + test('canceling single-chat isolation releases the workspace replacement guard without converting', async () => { + const isolation = new TestWorktreeIsolation(URI.file('/worktree')); + const harness = createHarness(isolation); + makeFolderSession(harness); + startTurn(harness.stateManager, harness.chat); + harness.service.requestChatIsolation(harness.chat, 'turn-1', 'client-1'); + assert.throws(() => harness.service.requestChatIsolation(harness.chat, 'turn-1', 'client-1'), /workspace cannot be changed to a new worktree/); + harness.service.cancel(harness.chat.toString(), 'turn-1'); + await updateSessionWorkspace(harness); + assert.deepStrictEqual({ + pending: harness.service.isPending(buildChatUri(harness.session, 'peer')), created: isolation.createdWorktrees, + }, { pending: false, created: [] }); + }); + + test('multi-chat isolation remains gated when the provider does not support multiple directories', () => { + const harness = createHarness(new TestWorktreeIsolation(URI.file('/worktree'))); + makeFolderSession(harness); + harness.agent.getDescriptor = () => ({ provider: 'copilot', displayName: 'Copilot', description: '' }); + const peer = buildChatUri(harness.session, 'peer'); + harness.stateManager.addChat(harness.session.toString(), peer); + startTurn(harness.stateManager, URI.parse(peer), 'peer-turn'); + startTurn(harness.stateManager, harness.chat); + assert.deepStrictEqual([harness.service.canIsolateChat(harness.chat), harness.service.canIsolateChat(URI.parse(peer))], [false, false]); + assert.throws(() => harness.service.requestChatIsolation(harness.chat, 'turn-1', 'client-1'), /workspace cannot be changed to a new worktree/); + assert.throws(() => harness.service.requestChatIsolation(URI.parse(peer), 'peer-turn', 'client-1'), /workspace cannot be changed to a new worktree/); + }); + + test('an uncertain single-chat conversion quarantines the caller and retains the worktree', async () => { + const isolation = new TestWorktreeIsolation(URI.file('/worktree')); + const harness = createHarness(isolation); + makeFolderSession(harness); + const provider: IAgent = harness.agent; + provider.setChatWorkingDirectory = async () => { + throw new AgentWorkingDirectoryChangedError(URI.file('/worktree'), 'Provider update was not confirmed'); + }; + startTurn(harness.stateManager, harness.chat); + harness.service.requestChatIsolation(harness.chat, 'turn-1', 'client-1'); + completeTurn(harness.stateManager, harness.chat); + await updateSessionWorkspace(harness); + assert.deepStrictEqual({ + quarantined: await harness.database.getMetadata('agentHost.chatIsolationQuarantined'), + mainBlocked: harness.service.isPending(harness.chat.toString()), + peerBlocked: harness.service.isPending(buildChatUri(harness.session, 'peer')), + removed: isolation.removedWorktrees, + continued: harness.continuations, + failed: harness.failedContinuations.length, + }, { quarantined: 'true', mainBlocked: true, peerBlocked: false, removed: [], continued: [], failed: 1 }); + }); + + test('aborts single-chat isolation if an in-flight chat creation changes the catalog during trust', async () => { + const trust = new DeferredPromise(); + const isolation = new TestWorktreeIsolation(URI.file('/worktree')); + const harness = createHarness(isolation, () => trust.p); + makeFolderSession(harness); + startTurn(harness.stateManager, harness.chat); + harness.service.requestChatIsolation(harness.chat, 'turn-1', 'client-1'); + completeTurn(harness.stateManager, harness.chat); + const conversion = updateSessionWorkspace(harness); + const newChat = buildChatUri(harness.session, 'new-peer'); + harness.stateManager.addChat(harness.session.toString(), newChat); + trust.complete(true); + await conversion; + assert.match(harness.continuations[0].message.text, /chat changed/); + assert.deepStrictEqual({ + created: isolation.createdWorktrees, + sessionDirectories: harness.stateManager.getSessionSummary(harness.session.toString())?.workingDirectories, + newPeerDirectories: harness.configurationService.getEffectiveWorkingDirectories(newChat), + pending: harness.service.isPending(harness.chat.toString()), + }, { created: [], sessionDirectories: [harness.scratch.toString()], newPeerDirectories: [harness.scratch.toString()], pending: false }); + }); + test('keeps a visible continuation in progress while converting after the invoking turn', async () => { const trustDecision = new DeferredPromise(); const harness = createHarness(new NullAgentHostWorktreeIsolation(), () => trustDecision.p); @@ -348,6 +1677,7 @@ suite('SessionWorkspaceConversionService', () => { await Promise.resolve(); providerMutation.complete(); await conversion; + assert.deepStrictEqual(harness.gitRefreshes, []); const state = harness.stateManager.getSessionState(harness.session.toString()); const activeTurn = harness.stateManager.getChatState(harness.chat.toString())?.activeTurn; @@ -402,7 +1732,7 @@ suite('SessionWorkspaceConversionService', () => { activity: undefined, activeTurnId: 'continuation-1', outcomeNotifications: [{ - content: 'Now working in project', + content: 'Workspace changed to project', meta: { kind: AgentSystemNotificationKind.WorkspaceTransition, severity: undefined, @@ -550,7 +1880,7 @@ suite('SessionWorkspaceConversionService', () => { workspaceContinuation: true, responseParts: [{ kind: ResponsePartKind.SystemNotification, - content: 'Now working in project', + content: 'Workspace changed to project', meta: { kind: AgentSystemNotificationKind.WorkspaceTransition, severity: undefined, @@ -714,7 +2044,10 @@ suite('SessionWorkspaceConversionService', () => { trustedParent: workspaceFolder.toString(), }], providerCalls: [worktreeIsolation.worktree.toString()], - sessionStateProject: undefined, + sessionStateProject: { + uri: workspaceFolder.toString(), + displayName: 'project', + }, summaryProject: { uri: workspaceFolder.toString(), displayName: 'project', @@ -731,7 +2064,7 @@ suite('SessionWorkspaceConversionService', () => { [SessionConfigKey.Branch]: 'main', }, outcomeNotifications: [{ - content: 'Now working in project', + content: 'Workspace changed to a new worktree of project', meta: { kind: AgentSystemNotificationKind.WorkspaceTransition, severity: undefined, @@ -773,6 +2106,29 @@ suite('SessionWorkspaceConversionService', () => { }); }); + for (const isolation of [false, true]) { + test(`quick-chat attachment preserves live and persisted settings with isolation=${isolation}`, async () => { + const harness = createHarness(new TestWorktreeIsolation(URI.file('/workspace/project.worktrees/task'))); + harness.agent.setWorkingDirectory = async () => { }; + const values = { + [SessionConfigKey.AutoApprove]: 'assisted', + [SessionConfigKey.Mode]: 'autopilot', + [SessionConfigKey.WorktreeIncludeFiles]: ['.env'], + }; + setSessionConfig(harness, values); + startTurn(harness.stateManager, harness.chat); + harness.service.requestSessionWorkspaceUpdate(harness.chat, 'turn-1', URI.file('/workspace/project'), isolation, 'client'); + completeTurn(harness.stateManager, harness.chat); + await updateSessionWorkspace(harness); + const expected = { ...values, isolation: isolation ? 'worktree' : 'folder', ...(isolation ? { branch: 'main' } : {}) }; + assert.deepStrictEqual({ + live: harness.stateManager.getSessionState(harness.session.toString())?.config?.values, + persisted: JSON.parse((await harness.database.getMetadata('configValues'))!), + continuations: harness.continuations.length, + }, { live: expected, persisted: expected, continuations: 1 }); + }); + } + test('does not request workspace trust in Allow All mode', async () => { const harness = createHarness(new NullAgentHostWorktreeIsolation(), async () => false); const workspaceFolder = URI.file('/workspace/project'); diff --git a/src/vs/platform/agentHost/test/node/shared/editArcReporter.test.ts b/src/vs/platform/agentHost/test/node/shared/editArcReporter.test.ts index 7e513d18323c59..d1c20cb9601349 100644 --- a/src/vs/platform/agentHost/test/node/shared/editArcReporter.test.ts +++ b/src/vs/platform/agentHost/test/node/shared/editArcReporter.test.ts @@ -164,6 +164,7 @@ suite('Agent Host Edit ARC Reporter', () => { initialEdit: { replacements: [{ start: 5, endExclusive: 5, text: ' AI' }] }, completionTime: Date.now(), }); + await timeout(10); assert.deepStrictEqual({ @@ -177,6 +178,19 @@ suite('Agent Host Edit ARC Reporter', () => { }); }); + test('standard session edit reports retain their explicit provider', async () => { + const resource = URI.file('/workspace/standard.ts'); + await fileService.writeFile(resource, VSBuffer.fromString('hello AI')); + const service = disposables.add(new EditArcReporterService([0], fileService, new TestDiffComputeService(), createNoopGitService(), config, new NullLogService(), telemetry)); + await service.reportEdit({ + sessionUri: 'ahp-session:/standard', provider: 'copilotcli', turnId: 'turn-1', toolCallId: 'tool-1', + filePath: resource.fsPath, beforeText: 'hello', afterText: 'hello AI', + initialEdit: { replacements: [{ start: 5, endExclusive: 5, text: ' AI' }] }, completionTime: Date.now(), + }); + await timeout(10); + assert.deepStrictEqual([telemetry.events[0].data.provider, telemetry.events[0].data.agentSessionId], ['copilotcli', 'standard']); + }); + test('updates older reporters before starting the next reporter', async () => { const resource = URI.file('/workspace/order.ts'); await fileService.writeFile(resource, VSBuffer.fromString('AIbase')); diff --git a/src/vs/platform/agentHost/test/node/shared/editSurvivalReporter.test.ts b/src/vs/platform/agentHost/test/node/shared/editSurvivalReporter.test.ts index f8fc4937639d42..ffc43eac06c92e 100644 --- a/src/vs/platform/agentHost/test/node/shared/editSurvivalReporter.test.ts +++ b/src/vs/platform/agentHost/test/node/shared/editSurvivalReporter.test.ts @@ -113,6 +113,7 @@ suite('agentHost editSurvivalReporter', () => { isCreate: false, aiChunks: ['after-text'], }); + disposables.add(reporter); await timeout(50); @@ -122,6 +123,19 @@ suite('agentHost editSurvivalReporter', () => { assert.strictEqual(data.agentSessionId, 'session-9'); }); + test('standard session samples retain their explicit provider', async () => { + await fileService.writeFile(URI.file('/workspace/standard.ts'), VSBuffer.fromString('after-text')); + const reporter = factory.launch({ + sessionUri: 'ahp-session:/standard', provider: 'claude', turnId: 'turn-1', toolCallId: 'tc-standard', + filePath: '/workspace/standard.ts', beforeText: 'before-text', afterText: 'after-text', + isCreate: false, modelId: 'model', toolName: 'Edit', + }); + disposables.add(reporter); + await timeout(50); + const data = telemetry.events[0].data as Record; + assert.deepStrictEqual([data.provider, data.agentSessionId], ['claude', 'standard']); + }); + test('emits a delete event when the file is missing', async () => { const reporter = factory.launch({ sessionUri: 'codex:/session-2', diff --git a/src/vs/platform/agentHost/test/node/shared/mcpCustomizationController.test.ts b/src/vs/platform/agentHost/test/node/shared/mcpCustomizationController.test.ts index f6647e63ddbeff..d9242d7ea8b3c8 100644 --- a/src/vs/platform/agentHost/test/node/shared/mcpCustomizationController.test.ts +++ b/src/vs/platform/agentHost/test/node/shared/mcpCustomizationController.test.ts @@ -10,7 +10,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/tes import { NullLogService } from '../../../../../platform/log/common/log.js'; import { AgentSession } from '../../../common/agent.js'; import { isCustomizationEnabled } from '../../../common/customizationEnablement.js'; -import { readMcpServerDisplayName, readMcpServerSource, withMcpServerSourceMeta } from '../../../common/meta/mcpCustomizationMeta.js'; +import { readMcpServerDisplayName, readMcpServerSource, readMcpServerSourcePlugin, withMcpServerSourceMeta, withMcpServerSourcePluginMeta } from '../../../common/meta/mcpCustomizationMeta.js'; import { ActionType } from '../../../common/state/protocol/common/actions.js'; import { CustomizationEnablementKind, CustomizationLoadStatus, CustomizationType, McpAuthRequiredReason, McpServerStatus, SessionStatus, type Customization, type CustomizationEnablement, type McpServerCustomization, type McpServerState, type PluginCustomization } from '../../../common/state/protocol/channels-session/state.js'; import { buildChatUri } from '../../../common/state/sessionState.js'; @@ -112,6 +112,14 @@ const PLUGIN_CUSTOMIZATIONS: readonly Customization[] = [ ]; suite('McpCustomizationController', () => { + for (const provider of ['copilotcli', 'codex', 'claude']) { + test(`standard chat channels carry explicit ${provider} routing`, () => { + const chat = URI.parse(buildChatUri('ahp-session:/fresh', 'peer')); + const channel = buildMcpChannel(chat, 'search', provider); + const route = parseMcpChannelUri(channel); + assert.deepStrictEqual(route && { ...route, chatUri: route.chatUri.toString() }, { providerId: provider, chatUri: chat.toString(), serverName: 'search' }); + }); + } const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -278,6 +286,52 @@ suite('McpCustomizationController', () => { ]); }); + test('publishes source plugins, keeps them through lifecycle updates, and clears them with the inventory', () => { + const { controller } = harness(store, { + customizations: [{ + type: CustomizationType.McpServer, + id: 'restored-computer-use', + uri: 'mcp-top-level:copilot:session-1:computer-use', + name: 'computer-use', + state: stopped(), + _meta: withMcpServerSourcePluginMeta({ 'test.opaque': 'kept' }, 'computer-use'), + }], + }); + store.add(controller); + const snapshot = () => controller.topLevelCustomizations().map(item => ({ name: item.name, sourcePlugin: readMcpServerSourcePlugin(item) })); + + controller.applyOne(server('computer-use', ready())); + const restored = snapshot(); + controller.applyAll([ + { ...server('computer-use', ready()), source: 'builtin', pluginName: 'computer-use' }, + { ...server('acme-server', ready()), source: 'plugin', pluginName: 'acme' }, + ]); + const inventory = snapshot(); + controller.applyOne(server('acme-server', stopped())); + const lifecycle = snapshot(); + controller.applyAll([ + { ...server('computer-use', ready()), source: 'builtin', pluginName: null }, + { ...server('acme-server', stopped()), source: 'user', pluginName: null }, + ]); + + assert.deepStrictEqual({ restored, inventory, lifecycle, cleared: snapshot(), restoredMeta: controller.topLevelCustomizations()[0]._meta }, { + restored: [{ name: 'computer-use', sourcePlugin: 'computer-use' }], + inventory: [ + { name: 'computer-use', sourcePlugin: 'computer-use' }, + { name: 'acme-server', sourcePlugin: 'acme' }, + ], + lifecycle: [ + { name: 'computer-use', sourcePlugin: 'computer-use' }, + { name: 'acme-server', sourcePlugin: 'acme' }, + ], + cleared: [ + { name: 'computer-use', sourcePlugin: undefined }, + { name: 'acme-server', sourcePlugin: undefined }, + ], + restoredMeta: { 'test.opaque': 'kept', 'agentHost.mcpServerSource': 'builtin' }, + }); + }); + test('reapplying an unchanged inventory dispatches nothing', () => { const { controller, actions } = harness(store, { customizations: PLUGIN_CUSTOMIZATIONS }); store.add(controller); diff --git a/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts b/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts index 3a60be655ebfb7..dbdb0bbf929690 100644 --- a/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts +++ b/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts @@ -18,7 +18,8 @@ import { SessionConfigKey } from '../../../common/sessionConfigKeys.js'; import { AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, MessageKind, ResponsePartKind, TurnState, type ISessionGitState, type Turn } from '../../../common/state/sessionState.js'; import { AgentBranchNameGenerator, IAgentBranchNameGenerator } from '../../../node/shared/agentBranchNameGenerator.js'; import { ICopilotApiService } from '../../../node/shared/copilotApiService.js'; -import { buildWorktreeFailureNotification, normalizeWorktreeFailureDiagnostic, NullAgentHostWorktreeIsolation, SessionWorkingDirectoryMissingError, WorktreeIsolation, getWorktreeName, getWorktreesRoot } from '../../../node/shared/worktreeIsolation.js'; +import { buildWorktreeFailureNotification, detachedWorktreeRecordUri, normalizeWorktreeFailureDiagnostic, NullAgentHostWorktreeIsolation, SessionWorkingDirectoryMissingError, WorktreeIsolation, getWorktreeName, getWorktreesRoot } from '../../../node/shared/worktreeIsolation.js'; +import { ADDITIONAL_WORKTREES_METADATA_KEY, readSessionAdditionalWorktrees } from '../../../node/shared/sessionAdditionalWorktrees.js'; import { TestSessionDatabase, createNoopGitService, createSessionDataService } from '../../common/sessionTestHelpers.js'; import type { ISessionDataService } from '../../../common/sessionDataService.js'; @@ -226,6 +227,206 @@ suite('WorktreeIsolation', () => { }); }); + for (const fail of [false, true]) { + test(`retains the previous owned worktree when changing workspace (persistence failure: ${fail})`, async () => { + db = new class extends TestSessionDatabase { + override async setMetadataValues(values: Readonly>): Promise { + if (fail && values[ADDITIONAL_WORKTREES_METADATA_KEY]) { + throw new Error('Retention persistence failed'); + } + await super.setMetadataValues(values); + } + }(); + const detached = new TestSessionDatabase(); + const service: ISessionDataService = { + ...createSessionDataService(db), + openDatabase: resource => createSessionDataService(resource.toString() === sessionUri.toString() ? db : detached).openDatabase(resource), + tryOpenDatabase: async resource => createSessionDataService(resource.toString() === sessionUri.toString() ? db : detached).openDatabase(resource), + }; + const isolation = createIsolation(disposables, { sessionDataService: service }); + const request = { sessionUri, sessionId, workingDirectory: repoRoot, config: { [SessionConfigKey.Isolation]: 'worktree', [SessionConfigKey.Branch]: 'main' } }; + const original = await isolation.resolveForWorkspaceConversion(request); + assert.ok(original); + await db.setMetadata(META_DIFF_BASE_BRANCH, 'origin/custom-base'); + if (fail) { + await assert.rejects(isolation.retainSessionWorktree(sessionUri, sessionId), /Retention persistence failed/); + assert.deepStrictEqual({ + owned: (await isolation.prepareSessionDeletion(sessionUri, sessionId))?.worktree.toString(), + retained: await readSessionAdditionalWorktrees(service, sessionUri), removed: removeCalls, + }, { owned: original.toString(), retained: [], removed: [] }); + return; + } + await isolation.retainSessionWorktree(sessionUri, sessionId); + const retained = await readSessionAdditionalWorktrees(service, sessionUri); + const restored = createIsolation(disposables, { sessionDataService: service }); + assert.deepStrictEqual({ + retained: retained.map(record => ({ directory: record.workingDirectory, repository: record.repositoryRoot })), + primary: await restored.prepareSessionDeletion(sessionUri, sessionId), + detached: (await restored.readWorktreeMetadata(detachedWorktreeRecordUri(retained[0].handle)))?.worktreePath?.toString(), + diffBase: await detached.getMetadata(META_DIFF_BASE_BRANCH), + exists: existsSync(original.fsPath), removed: removeCalls, + }, { + retained: [{ directory: original.toString(), repository: repoRoot.toString() }], + primary: undefined, detached: original.toString(), diffBase: 'origin/custom-base', exists: true, removed: [], + }); + branchName = 'agents/next-worktree'; + const next = await isolation.resolveForWorkspaceConversion(request); + assert.notStrictEqual(next?.toString(), original.toString(), 'A later conversion must not reuse the previous checkout'); + await restored.deleteDetachedWorktree(retained[0].handle); + assert.deepStrictEqual(removeCalls.map(call => call.worktree.toString()), [original.toString()]); + }); + } + + test('retention refuses to drop live ownership after creation metadata persistence fails', async () => { + db = new class extends TestSessionDatabase { + override async setMetadata(key: string, value: string): Promise { + if (key === 'copilot.worktree.branchName') { + throw new Error('Creation metadata persistence failed'); + } + await super.setMetadata(key, value); + } + }(); + const isolation = createIsolation(disposables); + const worktree = await isolation.resolveForWorkspaceConversion({ + sessionUri, sessionId, workingDirectory: repoRoot, + config: { [SessionConfigKey.Isolation]: 'worktree', [SessionConfigKey.Branch]: 'main' }, + }); + assert.ok(worktree); + await assert.rejects(isolation.retainSessionWorktree(sessionUri, sessionId), /without its persisted ownership metadata/); + const owned = await isolation.prepareSessionDeletion(sessionUri, sessionId); + assert.deepStrictEqual({ + live: isolation.getResolvedWorktree(sessionId)?.toString(), + deletion: owned?.worktree.toString(), + additional: await readSessionAdditionalWorktrees(createSessionDataService(db), sessionUri), + exists: existsSync(worktree.fsPath), + }, { live: worktree.toString(), deletion: worktree.toString(), additional: [], exists: true }); + await isolation.removeSessionWorktree(sessionId, owned); + assert.strictEqual(existsSync(worktree.fsPath), false); + }); + + for (const interruption of ['registration', 'claim'] as const) { + for (const retryBeforeRestart of [false, true, 'delete owner'] as const) { + test(`retention recovers interrupted ${interruption} (retry before restart: ${retryBeforeRestart})`, async () => { + let interrupt = true; + db = new class extends TestSessionDatabase { + override async setMetadataValues(values: Readonly>): Promise { + if (interrupt && interruption === 'registration' && values[ADDITIONAL_WORKTREES_METADATA_KEY]) { + throw new Error('Interrupted before owner registration'); + } + await super.setMetadataValues(values); + } + }(); + const databases = new Map([[sessionUri.toString(), db]]); + const service: ISessionDataService = { + ...createSessionDataService(db), + openDatabase: resource => { + let database = databases.get(resource.toString()); + if (!database) { + database = new class extends TestSessionDatabase { + override async setMetadataValues(values: Readonly>): Promise { + if (interrupt && interruption === 'claim' && values['vscode.devContainerWorktree.claimed'] === 'true') { + throw new Error('Interrupted before claim finalization'); + } + await super.setMetadataValues(values); + } + }(); + databases.set(resource.toString(), database); + } + return createSessionDataService(database).openDatabase(resource); + }, + tryOpenDatabase: async resource => { + const database = databases.get(resource.toString()); + return database ? createSessionDataService(database).openDatabase(resource) : undefined; + }, + listSessionDataIds: async prefix => [...databases.keys()].map(key => URI.parse(key).path.substring(1)).filter(id => id.startsWith(prefix)), + deleteSessionData: async resource => { databases.delete(resource.toString()); }, + }; + const logService = new TestLogService(); + const first = createIsolation(disposables, { sessionDataService: service, logService }); + const worktree = await first.resolveForWorkspaceConversion({ + sessionUri, sessionId, workingDirectory: repoRoot, + config: { [SessionConfigKey.Isolation]: 'worktree', [SessionConfigKey.Branch]: 'main' }, + }); + assert.ok(worktree); + if (interruption === 'registration') { + await assert.rejects(first.retainSessionWorktree(sessionUri, sessionId), /Interrupted before owner registration/); + } else { + await first.retainSessionWorktree(sessionUri, sessionId); + assert.ok(logService.warnings.some(warning => warning.includes('reconciliation will retry'))); + } + const provisional = [...databases.entries()].find(([key]) => key !== sessionUri.toString()); + assert.ok(provisional); + assert.deepStrictEqual({ + claimed: await provisional[1].getMetadata('vscode.devContainerWorktree.claimed'), + owned: (await first.prepareSessionDeletion(sessionUri, sessionId))?.worktree.toString(), + retained: (await readSessionAdditionalWorktrees(service, sessionUri)).length, + exists: existsSync(worktree.fsPath), removed: removeCalls, + }, { + claimed: 'false', + owned: interruption === 'registration' ? worktree.toString() : undefined, + retained: interruption === 'registration' ? 0 : 1, + exists: true, removed: [], + }); + + interrupt = false; + if (retryBeforeRestart === 'delete owner') { + await provisional[1].setMetadata('vscode.devContainerWorktree.createdAt', '0'); + await service.deleteSessionData(sessionUri); + const restored = createIsolation(disposables, { sessionDataService: service }); + await timeout(20); + await restored.reconcileDetachedWorktrees('unrelated-scope', []); + assert.deepStrictEqual({ + exists: existsSync(worktree.fsPath), records: databases.size, + removed: removeCalls.map(call => ({ path: call.worktree.toString(), force: call.force })), + }, { exists: false, records: 0, removed: [{ path: worktree.toString(), force: false }] }); + return; + } + if (retryBeforeRestart) { + await first.retainSessionWorktree(sessionUri, sessionId); + } + const restored = createIsolation(disposables, { sessionDataService: service }); + await timeout(20); + await restored.reconcileDetachedWorktrees('unrelated-scope', []); + assert.deepStrictEqual({ + exists: existsSync(worktree.fsPath), removed: removeCalls, + records: databases.size - 1, + original: (await restored.prepareSessionDeletion(sessionUri, sessionId))?.worktree.toString(), + }, { + exists: true, removed: [], + records: interruption === 'registration' && !retryBeforeRestart ? 0 : 1, + original: interruption === 'registration' && !retryBeforeRestart ? worktree.toString() : undefined, + }); + + await restored.retainSessionWorktree(sessionUri, sessionId); + const retained = await readSessionAdditionalWorktrees(service, sessionUri); + assert.strictEqual(retained.length, 1); + const handle = retained[0].handle; + await restored.recordAdoptedWorktreeMetadata(sessionUri, { + branchName, baseBranch: 'main', worktreePath: worktree, repositoryRoot: repoRoot, + }); + await restored.retainSessionWorktree(sessionUri, sessionId); + assert.deepStrictEqual((await readSessionAdditionalWorktrees(service, sessionUri)).map(record => record.handle), [handle]); + const record = databases.get(detachedWorktreeRecordUri(handle).toString()); + assert.ok(record); + await record.setMetadata('vscode.devContainerWorktree.createdAt', '0'); + await service.deleteSessionData(sessionUri); + hasUncommittedChanges = true; + createIsolation(disposables, { sessionDataService: service }); + await timeout(20); + assert.deepStrictEqual({ exists: existsSync(worktree.fsPath), records: databases.size, removed: removeCalls }, { + exists: true, records: 1, removed: [], + }); + hasUncommittedChanges = false; + await restored.reconcileDetachedWorktrees('unrelated-scope', []); + await restored.reconcileDetachedWorktrees('unrelated-scope', []); + assert.deepStrictEqual({ + exists: existsSync(worktree.fsPath), records: databases.size, + removed: removeCalls.map(call => ({ path: call.worktree.toString(), force: call.force })), + }, { exists: false, records: 0, removed: [{ path: worktree.toString(), force: false }] }); + }); + } + } + test('concurrent config resolutions share Git reads but later calls see changed branches', async () => { let rootReads = 0; let currentBranch = 'first'; @@ -903,6 +1104,88 @@ suite('WorktreeIsolation', () => { assert.deepStrictEqual({ dataIds: [...dataIds], removeCalls }, { dataIds: [], removeCalls: [] }); }); + function createOwnedRecordsHarness(unopenable: Set = new Set()) { + const databases = new Map(); + const base = createSessionDataService(db); + const service: ISessionDataService = { + ...base, + openDatabase: resource => { + let database = databases.get(resource.toString()); + if (!database) { + database = new TestSessionDatabase(); + databases.set(resource.toString(), database); + } + return createSessionDataService(database).openDatabase(resource); + }, + tryOpenDatabase: async resource => { + if (unopenable.has(resource.toString())) { + throw new Error('database disk image is malformed'); + } + const database = databases.get(resource.toString()); + return database ? createSessionDataService(database).openDatabase(resource) : undefined; + }, + listSessionDataIds: async prefix => [...databases.keys()].map(key => URI.parse(key).path.substring(1)).filter(id => id.startsWith(prefix)), + deleteSessionData: async resource => { databases.delete(resource.toString()); }, + }; + const isolation = createIsolation(disposables, { sessionDataService: service }); + const create = async (prompt: string) => { + branchName = `agents/${prompt}`; + const created = await isolation.createDetachedWorktree({ + workingDirectory: repoRoot, + config: { [SessionConfigKey.Isolation]: 'worktree', [SessionConfigKey.Branch]: 'main' }, + prompt, + }); + await isolation.claimDetachedWorktree(created.handle); + await databases.get(detachedWorktreeRecordUri(created.handle).toString())!.setMetadata('vscode.devContainerWorktree.lastSeenAt', '0'); + return created; + }; + const setOwner = (handle: string, ownerUri: URI) => databases.get(detachedWorktreeRecordUri(handle).toString())!.setMetadata('vscode.devContainerWorktree.owner', ownerUri.toString()); + return { databases, service, isolation, create, setOwner }; + } + + test('reconcileDetachedWorktrees retains records whose owner metadata is unreadable and still prunes the others', async () => { + const { databases, service, isolation, create, setOwner } = createOwnedRecordsHarness(); + const corruptOwner = await create('corrupt-owner'); + const gone = await create('gone'); + const ownerUri = URI.parse('agent-session://test/corrupt-owner'); + await service.openDatabase(ownerUri).object.setMetadata(ADDITIONAL_WORKTREES_METADATA_KEY, '{bad'); + await setOwner(corruptOwner.handle, ownerUri); + rmSync(gone.worktree.fsPath, { recursive: true, force: true }); + + await isolation.reconcileDetachedWorktrees(getComparisonKey(gone.worktree), []); + + assert.deepStrictEqual({ + records: [...databases.keys()].filter(key => key.includes('worktree')).sort(), + exists: existsSync(corruptOwner.worktree.fsPath), + removed: removeCalls, + }, { + records: [detachedWorktreeRecordUri(corruptOwner.handle).toString()], + exists: true, + removed: [], + }); + }); + + test('reconcileDetachedWorktrees retains records whose owner database cannot be opened and still prunes the others', async () => { + const ownerUri = URI.parse('agent-session://test/malformed-owner'); + const { databases, isolation, create, setOwner } = createOwnedRecordsHarness(new Set([ownerUri.toString()])); + const malformedOwner = await create('malformed-owner'); + const gone = await create('gone'); + await setOwner(malformedOwner.handle, ownerUri); + rmSync(gone.worktree.fsPath, { recursive: true, force: true }); + + await isolation.reconcileDetachedWorktrees(getComparisonKey(gone.worktree), []); + + assert.deepStrictEqual({ + records: [...databases.keys()].filter(key => key.includes('worktree')).sort(), + exists: existsSync(malformedOwner.worktree.fsPath), + removed: removeCalls, + }, { + records: [detachedWorktreeRecordUri(malformedOwner.handle).toString()], + exists: true, + removed: [], + }); + }); + test('resolveWorkingDirectory creates from the primary worktree while setting up files from the selected checkout', async () => { const checkoutRoot = URI.joinPath(repoRoot, 'linked-checkout'); const gitService = createGitService(); @@ -1351,6 +1634,40 @@ suite('WorktreeIsolation', () => { ); }); + test('resolveWorkingDirectoryForResume reports a missing directory when worktree metadata is unreadable', async () => { + const isolation = createIsolation(disposables); + const missingDirectory = URI.joinPath(repoRoot, 'missing-directory'); + const getMetadata = db.getMetadata.bind(db); + db.getMetadata = async key => { + if (key === 'copilot.worktree.branchName') { + throw new Error('unreadable worktree metadata'); + } + return getMetadata(key); + }; + + await assert.rejects( + () => isolation.resolveWorkingDirectoryForResume(sessionUri, sessionId, missingDirectory), + (error: Error) => error instanceof SessionWorkingDirectoryMissingError, + ); + }); + + for (const archived of [false, true]) { + test(`resume never substitutes a different owned worktree (archived: ${archived})`, async () => { + const isolation = createIsolation(disposables); + await db.setMetadataValues({ + 'copilot.worktree.branchName': 'feature/owner', + 'copilot.worktree.path': URI.joinPath(worktreesRoot, 'owner').toString(), + 'copilot.worktree.repositoryRoot': repoRoot.toString(), + [AH_META_IS_ARCHIVED_DB_KEY]: String(archived), + }); + await assert.rejects( + isolation.resolveWorkingDirectoryForResume(sessionUri, sessionId, URI.joinPath(worktreesRoot, 'peer')), + SessionWorkingDirectoryMissingError, + ); + assert.deepStrictEqual(addExistingCalls, []); + }); + } + test('resolveWorkingDirectoryForResume reports an archived session when its repository root is also missing', async () => { const isolation = createIsolation(disposables); const missingRepositoryRoot = URI.joinPath(repoRoot, 'missing-repository'); @@ -1690,6 +2007,20 @@ suite('WorktreeIsolation', () => { }); }); + test('discardSessionWorktree preserves restored folder metadata after failed isolation', async () => { + const isolation = createIsolation(disposables); + await db.setMetadata('copilot.workingDirectory', repoRoot.toString()); + await isolation.resolveWorkingDirectory({ sessionUri, sessionId, workingDirectory: repoRoot, config: { [SessionConfigKey.Isolation]: 'worktree', [SessionConfigKey.Branch]: 'main' } }); + await isolation.discardSessionWorktree(sessionUri, sessionId, await isolation.prepareSessionDeletion(sessionUri, sessionId), { preserveWorkingDirectory: true }); + assert.deepStrictEqual(await db.getMetadataObject({ + 'copilot.workingDirectory': true, + 'copilot.worktree.path': true, + }), { + 'copilot.workingDirectory': repoRoot.toString(), + 'copilot.worktree.path': undefined, + }); + }); + test('session deletion removes a persisted worktree after a process restart', async () => { const worktree = URI.joinPath(worktreesRoot, 'persisted-worktree'); mkdirSync(worktree.fsPath, { recursive: true }); diff --git a/src/vs/platform/github/README.md b/src/vs/platform/github/README.md index a5e4197dd455ad..f2d706bc94e01b 100644 --- a/src/vs/platform/github/README.md +++ b/src/vs/platform/github/README.md @@ -145,19 +145,25 @@ The helper does not promise full Chromium parity: SOCKS4/4a, native NTLM, ordere This is not a proxy for `IGitHubClient`'s nested functions, resources or disposables. There is no credential provider, token transfer, account selection or authenticated-client IPC in this preparation. Authenticated client/subscription migration requires a separately authorized rollout. Existing workbench engines, standalone hosting and web support remain in place. -Focused offline validation (from the repository root, with `COPILOT_HOME` cleared and an isolated test home): +### Public repository files -```powershell -npm run transpile-client -npm run test-node -- --run src\vs\platform\github\test\common\githubIpc.test.ts --run src\vs\platform\github\test\node\githubTransport.test.ts --run src\vs\platform\github\test\common\githubAnonymousClient.test.ts --run src\vs\platform\github\test\common\responseReader.test.ts -.\scripts\test.bat --run src\vs\workbench\services\github\test\browser\githubService.test.ts --run src\vs\platform\agentHost\test\node\agentHostBootstrap.test.ts --grep 'Workbench GitHub service|agentHostBootstrap (supplies product|reuses the host fetch|preserves an explicit host fetch)' -``` +[The anonymous client](common/anonymousClient.ts) exposes `get` and `getFile` directly. Both accept a `signal` of type `GitHubCancellation` (`AbortSignal | CancellationToken`) and anonymous read options. `get` stays confined to JSON reads under `apiBaseUri`. `getFile` resolves `GET /repos/{owner}/{repo}/commits/HEAD`, validates the commit SHA, then downloads `/{owner}/{repo}/{commitSha}/{path}` from `rawBaseUri`. It makes one REST API read and one raw-content download, with no Contents API request or `download_url` lookup. + +The two operations share one normalized signal and an absolute deadline (five minutes by default). Caller attribution, priority, and deadline apply to both; representation and ETag options apply only to the commit API read. Raw content is returned as UTF-8 text without JSON/base64 parsing, preserving its BOM and whitespace. The existing 1 MiB client byte limit still applies and oversized content fails explicitly. Callers own the anonymous client lease; `getFile` neither acquires another lease nor releases the caller's lease. + +Raw downloads use the same host-local fetch, admission queue, bounded body reader, cancellation and deadline handling as other engine traffic, but their quota identity is the raw origin rather than the API origin. A REST quota cooldown does not block a different raw origin; raw throttling is retained independently and does not block the API origin. Every raw request omits credentials, referrers, authorization and API-identification headers. Redirects must stay on the configured raw origin within the pinned repository/commit path. + +[The shared cancellation helper](common/cancellation.ts), `toAbortSignal`, passes existing signals and their abort reasons through unchanged. It converts tokens to signals that abort with `CancellationError`, registering the listener in the operation's disposable store. The anonymous client disposes its operation stores on success, failure, or cancellation. The GitHub IPC boundary passes its token directly to `get`; the internal request engine continues to use `AbortSignal`. + +`acquireAnonymousClient()` defaults to GitHub.com's API and `https://raw.githubusercontent.com`. Host options may override `rawBaseUri`; it is part of client identity along with `apiBaseUri`. An explicit non-GitHub.com API endpoint must also configure its raw base before using `getFile` (JSON `get` calls still work without it). Raw-host mappings are not guessed for Enterprise deployments. API and raw base URIs must be HTTPS and cannot contain credentials, a query or a fragment. + +Dev Container sample preparation leases the default anonymous client from the owning host's local `IGitHubService`, passes its `CancellationToken` directly to `client.getFile` with caller `devContainerSample` and interactive priority, and releases the lease when the read settles. There is no caller-side cancellation adapter or desktop IPC. Its `{ commit, content }` source cache, commit-pinned checkout, volume identities, and clone-before-hooks ordering are unchanged. Cache hits do not acquire a GitHub client or create a token adapter. -Network tests use injected fetchers or loopback servers, not live GitHub requests or inference. +Desktop sample reads inherit the shared-process proxy and certificate behavior and its documented Chromium-compatibility limits above. Standalone hosts retain their local `AgentHostProxyResolver.fetch` binding. ### Telemetry -[Request telemetry](common/githubRequestTelemetry.ts) uses the existing product telemetry service and usage-telemetry controls. Active five-minute windows emit one `githubRequestSummary` with traffic, outcome, rejection and queue counters, plus at most ten reservoir-sampled `githubRequestTiming` events. Disposal flushes completed observations best-effort; idle engines emit nothing. +[Request telemetry](common/githubRequestTelemetry.ts) uses the existing product telemetry service and usage-telemetry controls. Active ten-minute windows emit one `githubRequestSummary` with traffic, outcome, rejection and queue counters, plus at most ten reservoir-sampled `githubRequestTiming` events. Disposal flushes completed observations best-effort; idle engines emit nothing. Bindings notify the collector immediately when usage telemetry is disabled, discarding buffered aggregates and invalidating completion handles even if no request or timer callback runs during the opt-out. diff --git a/src/vs/platform/github/common/anonymousClient.ts b/src/vs/platform/github/common/anonymousClient.ts new file mode 100644 index 00000000000000..3ccf657639d61c --- /dev/null +++ b/src/vs/platform/github/common/anonymousClient.ts @@ -0,0 +1,133 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; +import { ILogService } from '../../log/common/log.js'; +import { GitHubCancellation, toAbortSignal } from './cancellation.js'; +import { resolveReadApiUrl } from './githubEndpoints.js'; +import { GitHubRateLimitCoordinator } from './githubRateLimitCoordinator.js'; +import { GitHubRequestMetadata } from './githubRequestMetadata.js'; +import { GitHubRequestTelemetry } from './githubRequestTelemetry.js'; +import { asObject, requiredSha } from './githubResponse.js'; +import { GitHubAnonymousReadOptions, GitHubRestResponse, GitHubTransport } from './githubTransport.js'; +import { GitHubRequestError, GitHubServiceOptions } from './githubTypes.js'; +import { encodePathSegments } from './githubUrls.js'; +import { RequestQueue } from './requestQueue.js'; +import { AnonymousAccount } from './types.js'; + +/** Decoded repository file content paired with the commit SHA it was read from. */ +export interface IGitHubRepositoryFile { + + /** Commit SHA of the repository revision the file was read from. */ + readonly commitSha: string; + + /** File contents decoded as UTF-8 text. */ + readonly content: string; +} + +/** Read-only access to public GitHub resources without authentication. */ +export interface IGitHubAnonymousClient { + + readonly authorization: { readonly kind: 'anonymous' }; + readonly apiBaseUri: string; + + /** Reads JSON from an API-relative path. */ + get(path: string, signal: GitHubCancellation, options?: GitHubAnonymousReadOptions): Promise>; + + /** Reads a file at the resolved repository HEAD. */ + getFile(owner: string, repo: string, path: string, signal: GitHubCancellation, options?: GitHubAnonymousReadOptions): Promise; +} + +/** Fetches public GitHub API data and repository files without authentication. */ +export class GitHubAnonymousClient extends Disposable implements IGitHubAnonymousClient { + + readonly authorization = Object.freeze({ kind: 'anonymous' as const }); + references = 0; + private readonly _account: AnonymousAccount; + private readonly _transport: GitHubTransport; + + constructor( + readonly apiBaseUri: string, + private readonly _rawBaseUri: string | undefined, + options: GitHubServiceOptions, + queue: RequestQueue, + rateLimits: GitHubRateLimitCoordinator, + telemetry: GitHubRequestTelemetry, + logService: ILogService, + ) { + super(); + const { host, origin } = new URL(apiBaseUri); + this._account = { kind: 'anonymous', host, origin }; + this._transport = this._register(new GitHubTransport(options.fetch, undefined, false, logService, { + coordination: { queue, rateLimits }, + requestMetadata: options.clientMetadata ? new GitHubRequestMetadata(options.clientMetadata, { + onDidChange: Event.None, + getApiBaseUri: () => apiBaseUri, + getGraphQlUri: () => apiBaseUri, + }) : undefined, + }, telemetry)); + } + + async get(path: string, signal: GitHubCancellation, options: GitHubAnonymousReadOptions = {}): Promise> { + const lifetime = new DisposableStore(); + try { + const abortSignal = toAbortSignal(signal, lifetime); + abortSignal.throwIfAborted(); + + const { url, apiBasePath } = resolveReadApiUrl(this.apiBaseUri, path); + return await this._transport.anonymousGet(this._account, apiBasePath, { ...options, url: url.href }, abortSignal); + } finally { + lifetime.dispose(); + } + } + + async getFile(owner: string, repo: string, path: string, signal: GitHubCancellation, options: GitHubAnonymousReadOptions = {}): Promise { + const lifetime = new DisposableStore(); + try { + const abortSignal = toAbortSignal(signal, lifetime); + abortSignal.throwIfAborted(); + + if (!this._rawBaseUri) { + throw new GitHubRequestError('GitHub raw-content endpoint is not configured for this host.', 'validation'); + } + + const requestOptions: GitHubAnonymousReadOptions = { ...options, deadline: options.deadline ?? Date.now() + 5 * 60_000 }; + const repositoryPath = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + + const commitResponse = await this.get(`${repositoryPath}/commits/HEAD`, abortSignal, requestOptions); + abortSignal.throwIfAborted(); + const commitSha = requiredSha(asObject(commitResponse.data, 'GitHub returned an invalid repository revision.'), 'sha'); + + const base = new URL(`${this._rawBaseUri}/`); + const root = new URL(`${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${commitSha}/`, base); + const url = new URL(encodePathSegments(path), root); + if (root.origin !== base.origin + || !root.pathname.startsWith(base.pathname) + || !url.href.startsWith(root.href) + || url.href === root.href) { + throw new GitHubRequestError('GitHub file path escaped its pinned repository revision.', 'validation'); + } + + const file = await this._transport.anonymousDownload({ kind: 'anonymous', host: root.host, origin: root.origin }, root.pathname, { + url: url.href, + maximumBytes: 1024 * 1024, + timeout: 5 * 60_000, + caller: requestOptions.caller, + priority: requestOptions.priority, + deadline: requestOptions.deadline, + }, abortSignal); + + abortSignal.throwIfAborted(); + if (file.truncated) { + throw new GitHubRequestError('GitHub repository file exceeded its byte limit.', 'responseTooLarge'); + } + + return { commitSha, content: file.text }; + } finally { + lifetime.dispose(); + } + } +} diff --git a/src/vs/platform/github/common/cancellation.ts b/src/vs/platform/github/common/cancellation.ts new file mode 100644 index 00000000000000..b964aba2570556 --- /dev/null +++ b/src/vs/platform/github/common/cancellation.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../base/common/cancellation.js'; +import { CancellationError } from '../../../base/common/errors.js'; +import { DisposableStore } from '../../../base/common/lifecycle.js'; + +export type GitHubCancellation = AbortSignal | CancellationToken; + +/** Passes signals through and scopes any token adapter to the operation's lifetime. */ +export function toAbortSignal(cancellation: GitHubCancellation, lifetime: DisposableStore): AbortSignal { + if (!CancellationToken.isCancellationToken(cancellation)) { + return cancellation; + } + + const controller = new AbortController(); + if (cancellation.isCancellationRequested) { + controller.abort(new CancellationError()); + } else { + lifetime.add(cancellation.onCancellationRequested(() => controller.abort(new CancellationError()))); + } + + return controller.signal; +} diff --git a/src/vs/platform/github/common/githubEndpoints.ts b/src/vs/platform/github/common/githubEndpoints.ts index c594723f09dbce..0243a53faabd64 100644 --- a/src/vs/platform/github/common/githubEndpoints.ts +++ b/src/vs/platform/github/common/githubEndpoints.ts @@ -4,11 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import { URI } from '../../../base/common/uri.js'; +import { GitHubRequestError } from './githubTypes.js'; /** GitHub endpoints derived from an optional Enterprise base URI, with no trailing slashes. */ export interface IGitHubEndpoints { /** REST API base (e.g. `https://api.github.com`). */ readonly apiBaseUri: string; + /** Raw-content base when known; custom hosts may configure it explicitly. */ + readonly rawBaseUri?: string; /** GraphQL endpoint (distinct from `apiBaseUri` for on-prem: `/api/graphql`, not `/api/v3/graphql`). */ readonly graphQlUri: string; /** OAuth authorization server URI. */ @@ -26,6 +29,7 @@ export const GITHUB_DOT_COM_COPILOT_API_BASE_URI = 'https://api.githubcopilot.co /** Canonical github.com endpoints, used when no enterprise URI is configured. */ const GITHUB_DOT_COM_ENDPOINTS: IGitHubEndpoints = { apiBaseUri: 'https://api.github.com', + rawBaseUri: 'https://raw.githubusercontent.com', graphQlUri: 'https://api.github.com/graphql', oauthServer: 'https://github.com/login/oauth', enterpriseHost: undefined, @@ -82,3 +86,17 @@ export function gitHubMcpServerUrl(copilotApiBaseUri: string | undefined): strin return undefined; } } + +/** Resolves an API-relative read path and rejects URLs that escape the configured GitHub API endpoint. */ +export function resolveReadApiUrl(apiBaseUri: string, path: string): { url: URL; apiBasePath: string } { + if (typeof path !== 'string' || !path.startsWith('/') || path.startsWith('//') || path.includes('\\')) { + throw new GitHubRequestError('GitHub reads require an API-relative path', 'validation'); + } + const url = new URL(`${apiBaseUri}${path}`); + const endpoint = new URL(apiBaseUri); + const apiBasePath = `${endpoint.pathname.replace(/\/+$/, '')}/`; + if (url.origin !== endpoint.origin || !url.pathname.startsWith(apiBasePath) || url.username || url.password || url.hash) { + throw new GitHubRequestError('GitHub read escaped its API endpoint', 'validation'); + } + return { url, apiBasePath }; +} diff --git a/src/vs/platform/github/common/githubIpc.ts b/src/vs/platform/github/common/githubIpc.ts index 9561f527dff00a..5dee71280e6306 100644 --- a/src/vs/platform/github/common/githubIpc.ts +++ b/src/vs/platform/github/common/githubIpc.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../base/common/cancellation.js'; -import { CancellationError } from '../../../base/common/errors.js'; import { Event } from '../../../base/common/event.js'; import { DisposableStore } from '../../../base/common/lifecycle.js'; import { hasKey } from '../../../base/common/types.js'; @@ -19,7 +18,7 @@ import { RequestErrorKind } from './types.js'; export const GITHUB_CHANNEL_NAME = 'github'; export const ISharedProcessGitHubService = createDecorator('sharedProcessGitHubService'); -export interface GitHubAnonymousRequest extends GitHubAnonymousClientOptions { +export interface GitHubAnonymousRequest extends Pick { readonly path: string; readonly options?: GitHubAnonymousReadOptions; } @@ -61,15 +60,10 @@ export class GitHubChannel implements IServerChannel { } private async getAnonymous(request: GitHubAnonymousRequest, token: CancellationToken): Promise> { - if (token.isCancellationRequested) { - throw new CancellationError(); - } const lifetime = new DisposableStore(); - const controller = new AbortController(); - lifetime.add(token.onCancellationRequested(() => controller.abort(new CancellationError()))); try { const client = lifetime.add(this.service.acquireAnonymousClient({ apiBaseUri: request.apiBaseUri })).object; - return { result: await client.get(request.path, controller.signal, request.options) }; + return { result: await client.get(request.path, token, request.options) }; } catch (error) { if (!(error instanceof GitHubRequestError)) { throw error; diff --git a/src/vs/platform/github/common/githubRequestTelemetry.ts b/src/vs/platform/github/common/githubRequestTelemetry.ts index 720dccbd936ed1..1be6e6c0f78b8d 100644 --- a/src/vs/platform/github/common/githubRequestTelemetry.ts +++ b/src/vs/platform/github/common/githubRequestTelemetry.ts @@ -122,7 +122,7 @@ type TimingClassification = { /** Privacy-safe request counters and sampled timings for GitHub engine traffic. */ export class GitHubRequestTelemetry extends Disposable { - static readonly interval = 5 * 60_000; + static readonly interval = 10 * 60_000; static readonly maximumSamples = 10; private readonly _timer = this._register(new MutableDisposable()); diff --git a/src/vs/platform/github/common/githubResponse.ts b/src/vs/platform/github/common/githubResponse.ts index a38f886330367d..70346058692eb1 100644 --- a/src/vs/platform/github/common/githubResponse.ts +++ b/src/vs/platform/github/common/githubResponse.ts @@ -61,6 +61,15 @@ export function requiredString(value: object, key: string): string { return property; } +/** Reads a 40-character lowercase hexadecimal Git SHA. */ +export function requiredSha(value: object, key: string): string { + const property = requiredString(value, key); + if (property.length !== 40 || /[^a-f0-9]/.test(property)) { + throw new GitHubRequestError(`GitHub response property ${key} was not a valid SHA`, 'malformedResponse'); + } + return property; +} + export function stringProperty(value: object, key: string): string | undefined { const property = Reflect.get(value, key); return typeof property === 'string' ? property : undefined; diff --git a/src/vs/platform/github/common/githubService.ts b/src/vs/platform/github/common/githubService.ts index 7325b84ae5bfe3..a377a58075c69c 100644 --- a/src/vs/platform/github/common/githubService.ts +++ b/src/vs/platform/github/common/githubService.ts @@ -8,8 +8,10 @@ import { Disposable, DisposableMap, DisposableStore, IDisposable, IReference, Mu import { createDecorator } from '../../instantiation/common/instantiation.js'; import { ILogService } from '../../log/common/log.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; +import { GitHubAnonymousClient, IGitHubAnonymousClient } from './anonymousClient.js'; import { GitHubCredentialService, IGitHubCredentials } from './githubCredentialService.js'; import { BackoffGate } from './backoff.js'; +import { deriveGitHubEndpoints, resolveReadApiUrl } from './githubEndpoints.js'; import { GitHubHostCapabilitiesService, IGitHubCapabilities } from './githubHostCapabilitiesService.js'; import { GitHubQueryService, IGitHubQuery } from './githubQueryServiceImpl.js'; import { GitHubRequestMetadata } from './githubRequestMetadata.js'; @@ -17,30 +19,25 @@ import { GitHubRequestTelemetry } from './githubRequestTelemetry.js'; import { RequestQueue } from './requestQueue.js'; import { GitHubRateLimitCoordinator } from './githubRateLimitCoordinator.js'; import { systemRequestScheduler } from './scheduler.js'; -import { GitHubAnonymousReadOptions, GitHubBootstrapReadOptions, GitHubRestResponse, GitHubTransport, IGitHubTransport } from './githubTransport.js'; +import { GitHubBootstrapReadOptions, GitHubRestResponse, GitHubTransport, IGitHubTransport } from './githubTransport.js'; import { GitHubAnonymousClientOptions, GitHubAuthorizationContext, GitHubBootstrapClientOptions, GitHubClientOptions, GitHubCredentialChange, GitHubRequestError, GitHubServiceOptions, IGitHubCredentialProvider, IGitHubEndpointProvider } from './githubTypes.js'; -import { AnonymousAccount, BootstrapAccount } from './types.js'; +import { BootstrapAccount } from './types.js'; import { IPullRequestMutations, PullRequestMutationService } from './pullRequestMutationService.js'; import { PullRequestQueryService } from './pullRequestQueryService.js'; import { IPullRequestResources, PullRequestResourceService } from './pullRequestResourceService.js'; export const IGitHubService = createDecorator('gitHubService'); +export type { IGitHubAnonymousClient }; + /** Runtime-owned GitHub engine providing isolated clients with shared admission and quota state. */ export interface IGitHubService { readonly _serviceBrand: undefined; acquireClient(options: GitHubClientOptions): IReference; - acquireAnonymousClient(options: GitHubAnonymousClientOptions): IReference; + acquireAnonymousClient(options?: GitHubAnonymousClientOptions): IReference; acquireBootstrapClient(options: GitHubBootstrapClientOptions): IReference; } -/** Credential-free public JSON reads confined to an approved API base. */ -export interface IGitHubAnonymousClient { - readonly authorization: { readonly kind: 'anonymous' }; - readonly apiBaseUri: string; - get(path: string, signal: AbortSignal, options?: GitHubAnonymousReadOptions): Promise>; -} - /** Explicit-credential reads that do not depend on account selection or accepted-token publication. */ export interface IGitHubBootstrapClient { readonly apiBaseUri: string; @@ -150,15 +147,20 @@ export class GitHubService extends Disposable implements IGitHubService { return { object: client, dispose: () => release.dispose() }; } - acquireAnonymousClient(options: GitHubAnonymousClientOptions): IReference { + acquireAnonymousClient(options: GitHubAnonymousClientOptions = deriveGitHubEndpoints(undefined)): IReference { if (this._store.isDisposed) { throw new GitHubRequestError('GitHub service was disposed', 'unknown'); } - const key = normalizeReadApiBaseUri(options.apiBaseUri); + const apiBaseUri = normalizeReadApiBaseUri(options.apiBaseUri); + const defaults = deriveGitHubEndpoints(undefined); + const rawBaseUri = options.rawBaseUri !== undefined + ? normalizeReadApiBaseUri(options.rawBaseUri) + : apiBaseUri === defaults.apiBaseUri ? defaults.rawBaseUri : undefined; + const key = JSON.stringify([apiBaseUri, rawBaseUri]); let client = this._anonymousClients.get(key); if (!client) { this._ensureClientCapacity(); - client = new GitHubAnonymousClient(key, this._options, this._queue, this._rateLimits, this._telemetry, this._logService); + client = new GitHubAnonymousClient(apiBaseUri, rawBaseUri, this._options, this._queue, this._rateLimits, this._telemetry, this._logService); this._anonymousClients.set(key, client); } const retained = client; @@ -306,41 +308,6 @@ class GitHubClient extends Disposable implements IGitHubClient { } } -/** Reference-counted public reader with no access to credential providers or private caches. */ -class GitHubAnonymousClient extends Disposable implements IGitHubAnonymousClient { - readonly authorization = Object.freeze({ kind: 'anonymous' as const }); - references = 0; - private readonly _account: AnonymousAccount; - private readonly _transport: GitHubTransport; - - constructor( - readonly apiBaseUri: string, - options: GitHubServiceOptions, - queue: RequestQueue, - rateLimits: GitHubRateLimitCoordinator, - telemetry: GitHubRequestTelemetry, - logService: ILogService, - ) { - super(); - const endpoint = new URL(apiBaseUri); - this._account = { kind: 'anonymous', host: endpoint.host, origin: endpoint.origin }; - this._transport = this._register(new GitHubTransport(options.fetch, undefined, false, logService, { - coordination: { queue, rateLimits }, - requestMetadata: options.clientMetadata ? new GitHubRequestMetadata(options.clientMetadata, { - onDidChange: Event.None, - getApiBaseUri: () => apiBaseUri, - getGraphQlUri: () => apiBaseUri, - }) : undefined, - }, telemetry)); - } - - async get(path: string, signal: AbortSignal, options: GitHubAnonymousReadOptions = {}): Promise> { - signal.throwIfAborted(); - const { url, apiBasePath } = resolveReadApiUrl(this.apiBaseUri, path); - return this._transport.anonymousGet(this._account, apiBasePath, { ...options, url: url.href }, signal); - } -} - /** Reference-counted reader for a supplied bootstrap credential and its private request state. */ class GitHubBootstrapClient extends Disposable implements IGitHubBootstrapClient { references = 0; @@ -388,16 +355,3 @@ function normalizeReadApiBaseUri(apiBaseUri: string): string { } return endpoint.href.replace(/\/+$/, ''); } - -function resolveReadApiUrl(apiBaseUri: string, path: string): { url: URL; apiBasePath: string } { - if (typeof path !== 'string' || !path.startsWith('/') || path.startsWith('//') || path.includes('\\')) { - throw new GitHubRequestError('GitHub reads require an API-relative path', 'validation'); - } - const url = new URL(`${apiBaseUri}${path}`); - const endpoint = new URL(apiBaseUri); - const apiBasePath = `${endpoint.pathname.replace(/\/+$/, '')}/`; - if (url.origin !== endpoint.origin || !url.pathname.startsWith(apiBasePath) || url.username || url.password || url.hash) { - throw new GitHubRequestError('GitHub read escaped its API endpoint', 'validation'); - } - return { url, apiBasePath }; -} diff --git a/src/vs/platform/github/common/githubTransport.ts b/src/vs/platform/github/common/githubTransport.ts index e5708d55c64d3a..e4299108fb9850 100644 --- a/src/vs/platform/github/common/githubTransport.ts +++ b/src/vs/platform/github/common/githubTransport.ts @@ -262,32 +262,50 @@ export class GitHubTransport extends Disposable implements IGitHubTransport { return this._trackRequest(signal, () => this._download(account, token, request, signal)); } + anonymousDownload( + account: AnonymousAccount, + basePath: string, + request: GitHubDownloadRequest, + signal: AbortSignal, + ): Promise { + return this._trackRequest(signal, () => this._download(account, undefined, request, signal, basePath)); + } + private async _download( - account: AccountHandle, - token: string, + account: AccountHandle | AnonymousAccount, + token: string | undefined, request: GitHubDownloadRequest, signal: AbortSignal, + anonymousBasePath?: string, ): Promise { signal.throwIfAborted(); + const anonymous = account.kind === 'anonymous'; + if (anonymous) { + const url = new URL(request.url); + validateDownloadUrl(url, this._allowInsecureLoopbackDownloads); + if (url.origin !== account.origin || !anonymousBasePath || !url.pathname.startsWith(anonymousBasePath)) { + throw new GitHubRequestError('GitHub anonymous download escaped its content endpoint.', 'authorization'); + } + } if (!Number.isFinite(request.maximumBytes) || !Number.isFinite(request.timeout)) { throw new GitHubRequestError('Invalid GitHub download limits', 'validation'); } const deadline = this._deadline(request, Math.max(0, request.timeout)); const priority = request.priority ?? 'interactive'; - return this._logRequest('download', formatDownloadUrl(request.url), account, priority, signal, () => this._enqueueWithRateLimit(account, getGitHubRestResource(request.url), priority, signal, async (combinedSignal, onDispatch) => { + return this._logRequest('download', formatDownloadUrl(request.url), account, priority, signal, () => this._enqueueWithRateLimit(account, anonymous ? 'raw' : getGitHubRestResource(request.url), priority, signal, async (combinedSignal, onDispatch) => { const initialOrigin = new URL(request.url).origin; let url = request.url; - let authenticated = true; + let authenticated = !anonymous; for (let redirectCount = 0; redirectCount <= maximumRedirects; redirectCount++) { combinedSignal.throwIfAborted(); - const resource = getGitHubRestResource(url); - if (authenticated && this._rateLimits.getRequestDelay(account, resource) > 0) { + const resource = anonymous ? 'raw' : getGitHubRestResource(url); + if ((authenticated || anonymous) && this._rateLimits.getRequestDelay(account, resource) > 0) { throw new GitHubRequestError('GitHub download is rate limited', 'rateLimit'); } const headers: Record = { ...(authenticated ? this._options.requestMetadata?.getHeaders(url, request.caller, false) : undefined), 'Accept': authenticated ? 'application/vnd.github+json' : 'text/plain, application/octet-stream', - 'X-GitHub-Api-Version': defaultApiVersion, + ...(anonymous ? {} : { 'X-GitHub-Api-Version': defaultApiVersion }), }; if (authenticated) { headers['Authorization'] = `Bearer ${token}`; @@ -302,6 +320,7 @@ export class GitHubTransport extends Disposable implements IGitHubTransport { headers, signal: combinedSignal, redirect: 'manual', + ...(anonymous ? { credentials: 'omit', referrerPolicy: 'no-referrer' } as const : {}), }); this._telemetry?.recordResponse(response.status); if (combinedSignal.aborted) { @@ -317,7 +336,7 @@ export class GitHubTransport extends Disposable implements IGitHubTransport { throw new GitHubRequestError(`GitHub download network request failed (host: ${formatDownloadUrl(url)}, redirect: ${redirectCount}, codes: ${formatNetworkErrorCodes(error)})`, 'network'); } this._logService?.trace(`[GitHubTransport] Download request returned HTTP ${response.status}`); - if (authenticated) { + if (authenticated || anonymous) { this._rateLimits.updateFromResponse(account, response, undefined, resource); } if ([301, 302, 307, 308].includes(response.status)) { @@ -335,21 +354,24 @@ export class GitHubTransport extends Disposable implements IGitHubTransport { throw new GitHubRequestError('GitHub download redirect used an invalid target', 'authorization'); } validateDownloadUrl(redirected, this._allowInsecureLoopbackDownloads); - authenticated = redirected.origin === initialOrigin; + if (anonymous && (redirected.origin !== initialOrigin || !anonymousBasePath || !redirected.pathname.startsWith(anonymousBasePath))) { + throw new GitHubRequestError('GitHub anonymous download redirect escaped its pinned content path.', 'authorization', response.status); + } + authenticated = !anonymous && redirected.origin === initialOrigin; this._logService?.trace(`[GitHubTransport] Following download redirect to ${formatDownloadUrl(redirected.href)} (authenticated: ${authenticated})`); url = redirected.href; continue; } if (!response.ok) { let diagnosticBody = ''; - if (authenticated && response.status === 403) { + if ((authenticated || anonymous) && response.status === 403) { const prefix = await this._readDownloadBody(response, Math.min(maximumDownloadErrorBytes, this._options.maximumResponseBytes), combinedSignal); diagnosticBody = new TextDecoder().decode(prefix.bytes); this._rateLimits.updateFromResponse(account, response, diagnosticBody, resource); } else if (response.body) { cancelResponseBody(response.body, this._logService); } - const kind = !authenticated && response.status === 403 ? 'authorization' : classifyHttpError(response, diagnosticBody); + const kind = !authenticated && !anonymous && response.status === 403 ? 'authorization' : classifyHttpError(response, diagnosticBody); if (response.status === 403 && kind === 'rateLimit') { this._telemetry?.record('rateLimitedResponses'); } @@ -358,7 +380,7 @@ export class GitHubTransport extends Disposable implements IGitHubTransport { const body = await this._readDownloadBody(response, Math.min(request.maximumBytes, this._options.maximumResponseBytes), combinedSignal); this._logService?.trace(`[GitHubTransport] Downloaded ${body.bytes.byteLength} byte(s) (truncated: ${body.truncated})`); return { - text: new TextDecoder().decode(body.bytes), + text: new TextDecoder('utf-8', { ignoreBOM: anonymous }).decode(body.bytes), truncated: body.truncated, bytesRead: body.bytes.byteLength, sourceUrl: url, diff --git a/src/vs/platform/github/common/githubTypes.ts b/src/vs/platform/github/common/githubTypes.ts index b667eb65ea236a..e769663c8de000 100644 --- a/src/vs/platform/github/common/githubTypes.ts +++ b/src/vs/platform/github/common/githubTypes.ts @@ -110,6 +110,8 @@ export interface GitHubClientOptions { /** Trusted API base for public reads; callers cannot override authentication or network execution. */ export interface GitHubAnonymousClientOptions { readonly apiBaseUri: string; + /** Raw-content base; GitHub.com has a default, while other hosts must supply it for file reads. */ + readonly rawBaseUri?: string; } /** Internal, read-only bootstrap capability; account provenance affects quota accounting, not authorization. */ diff --git a/src/vs/platform/github/common/githubUrls.ts b/src/vs/platform/github/common/githubUrls.ts index fe21d8c98c8835..e772a001e2c74a 100644 --- a/src/vs/platform/github/common/githubUrls.ts +++ b/src/vs/platform/github/common/githubUrls.ts @@ -6,6 +6,11 @@ import { URI } from '../../../base/common/uri.js'; import { deriveGitHubEndpoints } from './githubEndpoints.js'; +/** Encodes a repository path while preserving directory separators. */ +export function encodePathSegments(path: string): string { + return path.split('/').map(encodeURIComponent).join('/'); +} + /** Normalized key for comparing pull request URLs irrespective of case and trailing slashes. */ export function getPullRequestUrlKey(url: string): string { return url.trim().replace(/\/+$/, '').toLowerCase(); diff --git a/src/vs/platform/github/test/common/anonymousClient.test.ts b/src/vs/platform/github/test/common/anonymousClient.test.ts new file mode 100644 index 00000000000000..16a6830c5de1b5 --- /dev/null +++ b/src/vs/platform/github/test/common/anonymousClient.test.ts @@ -0,0 +1,980 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { restore, SinonSpy, spy } from 'sinon'; +import { DeferredPromise, timeout } from '../../../../base/common/async.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { CancellationError } from '../../../../base/common/errors.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { IReference, toDisposable } from '../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; +import { GitHubCancellation } from '../../common/cancellation.js'; +import { GitHubService, IGitHubAnonymousClient, IGitHubService } from '../../common/githubService.js'; +import { GitHubAnonymousReadOptions, GitHubTransport } from '../../common/githubTransport.js'; +import { GitHubAnonymousClientOptions, GitHubClientOptions, GitHubCredentialChange, GitHubRequestError, GitHubServiceOptions } from '../../common/githubTypes.js'; +import { RequestFetch } from '../../common/types.js'; + +const apiBaseUri = 'https://api.github.com'; +const account = { host: 'api.github.com', accountId: '101' }; +const authorizedOptions: GitHubClientOptions = { + apiBaseUri, graphQlUri: `${apiBaseUri}/graphql`, + authorization: { providerId: 'github', sessionId: 'session', scopes: ['repo'] }, +}; + +class RecordingGitHubService extends GitHubService { + readonly apiBases: string[] = []; + private readonly _getSpies = new Map, ReturnType>>(); + releasedClients = 0; + + get reads(): { readonly path: string; readonly signal: AbortSignal; readonly options: GitHubAnonymousReadOptions | undefined }[] { + return [...this._getSpies.values()].flatMap(spy => spy.getCalls().map(call => { + const [path, signal, options] = call.args; + assert.ok(!CancellationToken.isCancellationToken(signal)); + return { path, signal, options }; + })); + } + + override acquireAnonymousClient(options?: GitHubAnonymousClientOptions): IReference { + const reference = super.acquireAnonymousClient(options); + this.apiBases.push(reference.object.apiBaseUri); + if (!this._getSpies.has(reference.object)) { + this._getSpies.set(reference.object, spy(reference.object, 'get')); + } + const release = toDisposable(() => { + this.releasedClients++; + reference.dispose(); + }); + return { + object: reference.object, + dispose: () => release.dispose(), + }; + } +} + +suite('GitHub anonymous clients', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + const signal = () => new AbortController().signal; + const create = (options: GitHubServiceOptions) => store.add(new GitHubService(options, new NullLogService(), NullTelemetryService)); + + function assertRequestError(action: () => void, kind: GitHubRequestError['kind']): void { + function isExpectedError(error: unknown): boolean { + return error instanceof GitHubRequestError && error.kind === kind; + } + assert.throws(action, isExpectedError); + } + + test('defaults to the shared GitHub.com client when options are omitted', async () => { + const urls: string[] = []; + const service = create({ fetch: async input => { urls.push(String(input)); return new Response('{}'); } }); + const client = store.add(service.acquireAnonymousClient()).object; + const explicit = store.add(service.acquireAnonymousClient({ apiBaseUri })).object; + await client.get('/repos/owner/repo', signal()); + assert.deepStrictEqual({ + apiBaseUri: client.apiBaseUri, shared: client === explicit, urls, + }, { apiBaseUri, shared: true, urls: [`${apiBaseUri}/repos/owner/repo`] }); + }); + + test('public reads require no credential provider, identity lookup or ambient credentials', async () => { + const seen: { url: string; authorization: string | null; credentials: RequestCredentials | undefined; referrerPolicy: ReferrerPolicy | undefined; method: string | undefined; body: RequestInit['body'] }[] = []; + const service = create({ + fetch: async (input, init) => { + seen.push({ + url: String(input), authorization: new Headers(init?.headers).get('Authorization'), + credentials: init?.credentials, referrerPolicy: init?.referrerPolicy, method: init?.method, body: init?.body, + }); + return new Response('{"items":[]}'); + }, + }); + const client = store.add(service.acquireAnonymousClient({ apiBaseUri })).object; + const response = await client.get('/search/issues?q=test', signal()); + assert.deepStrictEqual({ authorization: client.authorization, response: response.data, seen }, { + authorization: { kind: 'anonymous' }, + response: { items: [] }, + seen: [{ url: `${apiBaseUri}/search/issues?q=test`, authorization: null, credentials: 'omit', referrerPolicy: 'no-referrer', method: 'GET', body: undefined }], + }); + assertRequestError(() => service.acquireClient(authorizedOptions), 'authentication'); + }); + + test('get rejects an already cancelled token without fetching', async () => { + const service = create({ fetch: async () => assert.fail('No fetch expected') }); + const client = store.add(service.acquireAnonymousClient()).object; + await assert.rejects(client.get('/repos/owner/repo', CancellationToken.Cancelled), CancellationError); + }); + + test('get propagates token cancellation and releases the request listener', async () => { + const cancelled = store.add(new Emitter()); + const started = new DeferredPromise(); + const service = create({ + fetch: async (_input, init) => { + assert.ok(init?.signal); + const signal = init.signal; + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + started.complete(signal); + }); + }, + }); + const client = store.add(service.acquireAnonymousClient()).object; + const pending = assert.rejects(client.get('/repos/owner/repo', { + isCancellationRequested: false, onCancellationRequested: cancelled.event, + }), CancellationError); + const active = await started.p; + cancelled.fire(); + await pending; + assert.deepStrictEqual({ aborted: active.aborted, listening: cancelled.hasListeners() }, { aborted: true, listening: false }); + }); + + for (const fails of [false, true]) { + test(`get retains its token listener until settlement and then removes it (fails: ${fails})`, async () => { + const cancelled = store.add(new Emitter()); + let listeningDuringRequest = false; + const service = create({ + fetch: async () => { + listeningDuringRequest = cancelled.hasListeners(); + return new Response('{}', { status: fails ? 404 : 200 }); + }, + }); + const client = store.add(service.acquireAnonymousClient()).object; + const pending = client.get('/repos/owner/repo', { isCancellationRequested: false, onCancellationRequested: cancelled.event }); + if (fails) { + await assert.rejects(pending, { kind: 'notFound' }); + } else { + await pending; + } + assert.deepStrictEqual({ listeningDuringRequest, listeningAfterRequest: cancelled.hasListeners() }, { + listeningDuringRequest: true, listeningAfterRequest: false, + }); + }); + } + + test('get removes its token listener when path validation fails', async () => { + const cancelled = store.add(new Emitter()); + const service = create({ fetch: async () => assert.fail('No fetch expected') }); + const client = store.add(service.acquireAnonymousClient()).object; + await assert.rejects(client.get('//other.example.test', { + isCancellationRequested: false, onCancellationRequested: cancelled.event, + }), { kind: 'validation' }); + assert.strictEqual(cancelled.hasListeners(), false); + }); + + test('anonymous clients share an origin without being invalidated by sign-out', async () => { + const changed = store.add(new Emitter()); + const service = create({ + credentialProvider: { onDidChange: changed.event, getToken: () => { throw new Error('Anonymous calls must not request a token'); } }, + fetch: async () => new Response('{"public":true}'), + }); + const first = store.add(service.acquireAnonymousClient({ apiBaseUri })); + const peer = store.add(service.acquireAnonymousClient({ apiBaseUri: 'https://API.GITHUB.COM/' })); + first.dispose(); + changed.fire({ providerId: 'github' }); + const response = await peer.object.get('/repos/owner/repo', signal()); + assert.deepStrictEqual({ shared: first.object === peer.object, response: response.data }, { shared: true, response: { public: true } }); + }); + + test('private ETags and responses cannot enter the anonymous cache', async () => { + const seen: { authorization: boolean; etag: string | null }[] = []; + const service = create({ + credentialProvider: { onDidChange: Event.None, getToken: () => 'token' }, + fetch: async (_input, init) => { + const headers = new Headers(init?.headers); + const authorization = headers.has('Authorization'); + const etag = headers.get('If-None-Match'); + seen.push({ authorization, etag }); + return etag + ? new Response(null, { status: 304 }) + : new Response(JSON.stringify({ visibility: authorization ? 'private' : 'public' }), { headers: { ETag: authorization ? '"private"' : '"public"' } }); + }, + }); + const authorized = store.add(service.acquireClient(authorizedOptions)).object; + const anonymous = store.add(service.acquireAnonymousClient({ apiBaseUri })).object; + const url = `${apiBaseUri}/repos/owner/repo`; + await authorized.transport.rest(account, 'token', { method: 'GET', url }, signal()); + await anonymous.get('/repos/owner/repo', signal()); + const reused = await anonymous.get('/repos/owner/repo', signal()); + assert.deepStrictEqual({ seen, publicData: reused.data }, { + seen: [{ authorization: true, etag: null }, { authorization: false, etag: null }, { authorization: false, etag: '"public"' }], + publicData: { visibility: 'public' }, + }); + }); + + test('anonymous reads coalesce and cancellation preserves a live peer', async () => { + const started = new DeferredPromise(); + const response = new DeferredPromise(); + let calls = 0; + const service = create({ + fetch: async (_input, init) => { + calls++; + assert.ok(init?.signal); + await started.complete(init.signal); + return response.p; + }, + }); + const first = store.add(service.acquireAnonymousClient({ apiBaseUri })); + const second = store.add(service.acquireAnonymousClient({ apiBaseUri })); + const controller = new AbortController(); + const reason = new Error('cancelled'); + const rejected = assert.rejects(first.object.get('/repos/owner/repo', controller.signal), error => error === reason); + const peer = second.object.get('/repos/owner/repo', signal()); + const active = await started.p; + controller.abort(reason); + first.dispose(); + await rejected; + await response.complete(new Response('{"ok":true}')); + await peer; + assert.deepStrictEqual({ calls, aborted: active.aborted }, { calls: 1, aborted: false }); + }); + + test('last-reference disposal stops only anonymous work and later reads can reacquire', async () => { + const started = new DeferredPromise(); + let calls = 0; + const service = create({ + fetch: async (_input, init) => { + if (++calls === 1) { + assert.ok(init?.signal); + const signal = init.signal; + await started.complete(signal); + return new Promise((_resolve, reject) => signal.addEventListener('abort', () => reject(signal.reason), { once: true })); + } + return new Response('{}'); + }, + }); + const reference = store.add(service.acquireAnonymousClient({ apiBaseUri })); + const pending = assert.rejects(reference.object.get('/repos/owner/repo', signal())); + const active = await started.p; + reference.dispose(); + await pending; + await assert.rejects(reference.object.get('/repos/owner/repo', signal()), /disposed/); + const replacement = store.add(service.acquireAnonymousClient({ apiBaseUri })).object; + await replacement.get('/repos/owner/repo', signal()); + assert.deepStrictEqual({ aborted: active.aborted, replaced: replacement !== reference.object, calls }, { aborted: true, replaced: true, calls: 2 }); + }); + + for (const mode of ['shared-origin', 'reacquired'] as const) { + test(`anonymous cooldown survives ${mode} while authenticated traffic remains independent`, () => runWithFakedTimers({}, async () => { + const started = Date.now(); + const requests: { url: string; at: number; authenticated: boolean }[] = []; + const service = create({ + credentialProvider: { onDidChange: Event.None, getToken: () => 'token' }, + fetch: async (input, init) => { + requests.push({ url: String(input), at: Date.now() - started, authenticated: new Headers(init?.headers).has('Authorization') }); + return requests.length === 1 ? new Response('{"message":"secondary rate limit"}', { status: 403, headers: { 'Retry-After': '5' } }) : new Response('{}'); + }, + }); + try { + const first = store.add(service.acquireAnonymousClient({ apiBaseUri })); + await assert.rejects(first.object.get('/repos/owner/repo', signal()), { kind: 'rateLimit' }); + if (mode === 'reacquired') { + first.dispose(); + } + const second = store.add(service.acquireAnonymousClient({ apiBaseUri: mode === 'shared-origin' ? `${apiBaseUri}/api/v3` : apiBaseUri })); + const authorized = store.add(service.acquireClient(authorizedOptions)).object; + await authorized.transport.rest(account, 'token', { method: 'GET', url: `${apiBaseUri}/repos/owner/other` }, signal()); + await second.object.get('/search/issues?q=test', signal()); + assert.deepStrictEqual(requests, [ + { url: `${apiBaseUri}/repos/owner/repo`, at: 0, authenticated: false }, + { url: `${apiBaseUri}/repos/owner/other`, at: 0, authenticated: true }, + { url: `${second.object.apiBaseUri}/search/issues?q=test`, at: 5_000, authenticated: false }, + ]); + } finally { + service.dispose(); + } + })); + } + + test('authenticated failures never trigger anonymous fallback or borrow another credential', async () => { + const requests: boolean[] = []; + const service = create({ + credentialProvider: { onDidChange: Event.None, getToken: () => { throw new Error('Unexpected credential selection'); } }, + fetch: async (_input, init) => { + requests.push(new Headers(init?.headers).has('Authorization')); + return new Response('{"message":"Not Found"}', { status: 404 }); + }, + }); + const authorized = store.add(service.acquireClient(authorizedOptions)).object; + const anonymous = store.add(service.acquireAnonymousClient({ apiBaseUri })).object; + await assert.rejects(authorized.transport.rest(account, 'token', { method: 'GET', url: `${apiBaseUri}/private` }, signal()), { kind: 'notFound' }); + await assert.rejects(anonymous.get('/private', signal()), { kind: 'notFound' }); + assert.deepStrictEqual(requests, [true, false]); + }); + + test('anonymous and authenticated contexts share the engine client-capacity limit', () => { + const service = create({ credentialProvider: { onDidChange: Event.None, getToken: () => 'token' } }); + for (let i = 0; i < 63; i++) { + store.add(service.acquireClient({ ...authorizedOptions, authorization: { ...authorizedOptions.authorization, sessionId: String(i) } })); + } + const publicClient = store.add(service.acquireAnonymousClient({ apiBaseUri })); + assertRequestError(() => service.acquireAnonymousClient({ apiBaseUri: 'https://other.example.test' }), 'overloaded'); + publicClient.dispose(); + const replacement = store.add(service.acquireAnonymousClient({ apiBaseUri: 'https://other.example.test' })); + assert.strictEqual(replacement.object.apiBaseUri, 'https://other.example.test'); + }); + + test('rejects unsafe endpoints and paths before invoking fetch', async () => { + let calls = 0; + const service = create({ fetch: async () => { calls++; return new Response('{}'); } }); + for (const endpoint of ['not a URL', 'http://api.github.com', 'https://name:password@api.github.com', 'https://api.github.com?secret=value', 'https://api.github.com#fragment']) { + assertRequestError(() => service.acquireAnonymousClient({ apiBaseUri: endpoint }), 'validation'); + } + const client = store.add(service.acquireAnonymousClient({ apiBaseUri: 'https://enterprise.example.test/api/v3' })).object; + for (const path of ['https://other.example.test', '//other.example.test', '/../../outside', '/%2e%2e/%2e%2e/outside', '/repos\\owner', '/repos#fragment']) { + await assert.rejects(client.get(path, signal()), { kind: 'validation' }); + } + assert.strictEqual(calls, 0); + }); + + test('redirects never attach credentials and cannot change origin', async () => { + const requests: { url: string; credentialless: boolean }[] = []; + const service = create({ + fetch: async (input, init) => { + requests.push({ url: String(input), credentialless: init?.credentials === 'omit' && !new Headers(init.headers).has('Authorization') }); + return new Response(null, { status: 302, headers: { Location: requests.length === 1 ? '/renamed' : 'https://other.example.test/data' } }); + }, + }); + const client = store.add(service.acquireAnonymousClient({ apiBaseUri })).object; + await assert.rejects(client.get('/old', signal()), { kind: 'authorization' }); + assert.deepStrictEqual(requests, [{ url: `${apiBaseUri}/old`, credentialless: true }, { url: `${apiBaseUri}/renamed`, credentialless: true }]); + }); + + for (const location of ['/outside', '../outside', '/api/v30/outside', '/api/v3/../outside', '/api/v3/%2e%2e/outside', '/api/v3']) { + test(`rejects anonymous redirects outside the API base: ${location}`, async () => { + const baseUri = 'https://enterprise.example.test/api/v3'; + const requests: string[] = []; + const service = create({ + fetch: async input => { + requests.push(String(input)); + return String(input) === `${baseUri}/old` + ? new Response(null, { status: 302, headers: { Location: location } }) + : new Response('{"outside":true}', { headers: { ETag: '"outside"' } }); + }, + }); + const client = store.add(service.acquireAnonymousClient({ apiBaseUri: `${baseUri}///` })).object; + await assert.rejects(client.get('/old', signal()), { kind: 'authorization' }); + await assert.rejects(client.get('/old', signal()), { kind: 'authorization' }); + assert.deepStrictEqual(requests, [`${baseUri}/old`, `${baseUri}/old`]); + }); + } + + test('anonymous redirects within the normalized API base retain conditional caching', async () => { + const baseUri = 'https://enterprise.example.test/api/v3'; + const requests: { url: string; etag: string | null }[] = []; + const service = create({ + fetch: async (input, init) => { + const etag = new Headers(init?.headers).get('If-None-Match'); + requests.push({ url: String(input), etag }); + if (String(input) === `${baseUri}/old`) { + return new Response(null, { status: 301, headers: { Location: 'renamed' } }); + } + return etag + ? new Response(null, { status: 304 }) + : new Response('{"public":true}', { headers: { ETag: '"public"' } }); + }, + }); + const client = store.add(service.acquireAnonymousClient({ apiBaseUri: 'https://enterprise.example.test/ignored/../api/v3///' })).object; + await client.get('/old', signal()); + const reused = await client.get('/old', signal()); + assert.deepStrictEqual({ requests, statusCode: reused.statusCode, data: reused.data, finalUrl: reused.finalUrl }, { + requests: [ + { url: `${baseUri}/old`, etag: null }, + { url: `${baseUri}/renamed`, etag: null }, + { url: `${baseUri}/renamed`, etag: '"public"' }, + ], + statusCode: 304, data: { public: true }, finalUrl: `${baseUri}/renamed`, + }); + }); + + test('a cached anonymous redirect cannot redirect outside the API base', async () => { + const baseUri = 'https://enterprise.example.test/api/v3'; + const requests: string[] = []; + const service = create({ + fetch: async input => { + requests.push(String(input)); + if (String(input) === `${baseUri}/old`) { + return new Response(null, { status: 302, headers: { Location: '/api/v3/renamed' } }); + } + return requests.length === 3 + ? new Response(null, { status: 307, headers: { Location: '/outside' } }) + : new Response('{"public":true}', { headers: { ETag: '"public"' } }); + }, + }); + const client = store.add(service.acquireAnonymousClient({ apiBaseUri: baseUri })).object; + await client.get('/old', signal()); + await assert.rejects(client.get('/old', signal()), { kind: 'authorization' }); + assert.deepStrictEqual(requests, [`${baseUri}/old`, `${baseUri}/renamed`, `${baseUri}/renamed`]); + }); + + test('anonymous API path boundaries do not restrict authenticated redirects', async () => { + const baseUri = 'https://enterprise.example.test/api/v3'; + const requests: string[] = []; + const service = create({ + credentialProvider: { onDidChange: Event.None, getToken: () => 'token' }, + fetch: async input => { + requests.push(String(input)); + return requests.length === 1 + ? new Response(null, { status: 302, headers: { Location: '/outside' } }) + : new Response('{"authenticated":true}'); + }, + }); + const client = store.add(service.acquireClient({ ...authorizedOptions, apiBaseUri: baseUri, graphQlUri: 'https://enterprise.example.test/api/graphql' })).object; + const response = await client.transport.rest({ host: 'enterprise.example.test', accountId: '101' }, 'token', { method: 'GET', url: `${baseUri}/old` }, signal()); + assert.deepStrictEqual({ requests, data: response.data }, { + requests: [`${baseUri}/old`, 'https://enterprise.example.test/outside'], data: { authenticated: true }, + }); + }); + + test('anonymous work participates in the same host concurrency bound as authenticated work', async () => { + const gates = Array.from({ length: 3 }, () => new DeferredPromise()); + const requests: boolean[] = []; + let active = 0; + let highWater = 0; + const service = create({ + credentialProvider: { onDidChange: Event.None, getToken: () => 'token' }, + fetch: async (_input, init) => { + const index = requests.length; + requests.push(new Headers(init?.headers).has('Authorization')); + highWater = Math.max(highWater, ++active); + try { + return await gates[index].p; + } finally { + active--; + } + }, + }); + const anonymous = store.add(service.acquireAnonymousClient({ apiBaseUri })).object; + const authenticated = store.add(service.acquireClient(authorizedOptions)).object; + const operations = [ + anonymous.get('/repos/owner/public', signal(), { caller: 'public' }), + authenticated.transport.rest(account, 'token', { method: 'GET', url: `${apiBaseUri}/repos/owner/first`, caller: 'first' }, signal()), + authenticated.transport.rest({ ...account, accountId: '202' }, 'other-token', { method: 'GET', url: `${apiBaseUri}/repos/owner/second`, caller: 'second' }, signal()), + ]; + await timeout(0); + const initiallyStarted = requests.length; + for (const gate of gates) { + await gate.complete(new Response('{}')); + } + await Promise.all(operations); + assert.deepStrictEqual({ initiallyStarted, highWater, requests }, { initiallyStarted: 2, highWater: 2, requests: [false, true, true] }); + }); + + test('different API paths on the same origin cannot multiply anonymous concurrency', async () => { + const gate = new DeferredPromise(); + const requests: string[] = []; + const service = create({ + fetch: async input => { + requests.push(String(input)); + return requests.length === 1 ? gate.p : new Response('{}'); + }, + }); + const first = store.add(service.acquireAnonymousClient({ apiBaseUri })).object; + const otherPath = store.add(service.acquireAnonymousClient({ apiBaseUri: `${apiBaseUri}/api/v3` })).object; + const operations = [first.get('/first', signal()), otherPath.get('/second', signal())]; + await timeout(0); + const beforeRelease = requests.length; + await gate.complete(new Response('{}')); + await Promise.all(operations); + assert.deepStrictEqual({ beforeRelease, requests }, { + beforeRelease: 1, requests: [`${apiBaseUri}/first`, `${apiBaseUri}/api/v3/second`], + }); + }); + + test('anonymous work respects deadlines while parked by a server cooldown', () => runWithFakedTimers({}, async () => { + let calls = 0; + const service = create({ fetch: async () => { calls++; return new Response(null, { status: 429, headers: { 'Retry-After': '60' } }); } }); + try { + const client = store.add(service.acquireAnonymousClient({ apiBaseUri })).object; + await assert.rejects(client.get('/repos/owner/repo', signal()), { kind: 'rateLimit' }); + const pending = assert.rejects(client.get('/repos/owner/other', signal(), { deadline: Date.now() + 50 }), { kind: 'timeout' }); + await timeout(50); + await pending; + assert.strictEqual(calls, 1); + } finally { + service.dispose(); + } + })); +}); + +suite('GitHub public repository files', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + teardown(() => restore()); + const commitSha = 'a'.repeat(40); + const rawBaseUri = 'https://raw.githubusercontent.com'; + const path = '.devcontainer/devcontainer.json'; + const paths = ['/repos/microsoft/sample/commits/HEAD']; + const rawPath = `/microsoft/sample/${commitSha}/${path}`; + const content = '{ // JSONC and UTF-8\n"image":"image","name":"caf\u00e9",}'; + + function fileResponse(input: Parameters[0], text = content): Response { + return new Response(String(input).endsWith('/commits/HEAD') ? JSON.stringify({ sha: commitSha }) : text); + } + + function create(fetch: RequestFetch): RecordingGitHubService { + return store.add(new RecordingGitHubService({ + fetch, + credentialProvider: { + onDidChange: Event.None, + getToken: () => { throw new Error('Public repository reads must not request credentials'); }, + }, + }, new NullLogService(), NullTelemetryService)); + } + + async function read(service: IGitHubService, signal: GitHubCancellation = new AbortController().signal, options?: GitHubAnonymousReadOptions) { + const client = store.add(service.acquireAnonymousClient()); + try { + return await client.object.getFile('microsoft', 'sample', path, signal, options); + } finally { + client.dispose(); + } + } + + test('equivalent anonymous leases share a client without fetching at construction', () => { + const service = create(async () => { throw new Error('No fetch expected'); }); + const first = store.add(service.acquireAnonymousClient({ apiBaseUri })); + const second = store.add(service.acquireAnonymousClient({ apiBaseUri })); + const other = store.add(service.acquireAnonymousClient({ apiBaseUri: 'https://github.example.test/api/v3' })); + assert.deepStrictEqual({ + sameClient: first.object === second.object, + differentEndpoint: first.object !== other.object, reads: service.reads, + }, { sameClient: true, differentEndpoint: true, reads: [] }); + }); + + test('raw endpoint options participate in client identity without changing API identity', () => { + const service = create(async () => assert.fail('No fetch expected')); + const defaults = store.add(service.acquireAnonymousClient()).object; + const explicit = store.add(service.acquireAnonymousClient({ apiBaseUri, rawBaseUri: 'https://RAW.GITHUBUSERCONTENT.COM///' })).object; + const other = store.add(service.acquireAnonymousClient({ apiBaseUri, rawBaseUri: 'https://raw.example.test' })).object; + assert.deepStrictEqual({ + shared: defaults === explicit, differentRawEndpoint: defaults !== other, apiBaseUri: other.apiBaseUri, + }, { shared: true, differentRawEndpoint: true, apiBaseUri }); + }); + + test('custom API hosts require an explicit raw endpoint for file reads', async () => { + const service = create(async () => assert.fail('No fetch expected')); + const client = store.add(service.acquireAnonymousClient({ apiBaseUri: 'https://github.example.test/api/v3' })).object; + await assert.rejects(client.getFile('microsoft', 'sample', path, CancellationToken.None), { kind: 'validation' }); + assert.deepStrictEqual(service.reads, []); + }); + + test('rejects invalid raw endpoints rather than falling back to GitHub.com', () => { + const service = create(async () => assert.fail('No fetch expected')); + for (const rawBaseUri of ['', 'not a URL', 'http://raw.example.test', 'https://user@raw.example.test', 'https://raw.example.test?query=value', 'https://raw.example.test#fragment']) { + function isValidationError(error: unknown): boolean { + return error instanceof GitHubRequestError && error.kind === 'validation'; + } + assert.throws(() => service.acquireAnonymousClient({ apiBaseUri, rawBaseUri }), isValidationError); + } + }); + + test('rejects file reads after the anonymous client is disposed', async () => { + const service = create(async () => { throw new Error('No fetch expected'); }); + const client = store.add(service.acquireAnonymousClient({ apiBaseUri })); + client.dispose(); + await assert.rejects(client.object.getFile('microsoft', 'sample', path, CancellationToken.None), { kind: 'unknown', message: 'GitHub client was disposed' }); + assert.strictEqual(service.releasedClients, 1); + }); + + test('uses the configured API and raw bases without acquiring another lease', async () => { + const endpoint = 'https://github.example.test/api/v3'; + const rawEndpoint = 'https://github.example.test/raw'; + const requests: Request[] = []; + const service = create(async (input, init) => { + requests.push(new Request(input, init)); + return fileResponse(input); + }); + const client = store.add(service.acquireAnonymousClient({ apiBaseUri: endpoint, rawBaseUri: rawEndpoint })); + const first = await client.object.getFile('microsoft', 'sample', path, CancellationToken.None); + const second = await client.object.getFile('microsoft', 'sample', path, new AbortController().signal); + assert.deepStrictEqual({ + first, second, apiBases: service.apiBases, releasedClients: service.releasedClients, + requests: requests.map(request => ({ url: request.url, authorization: request.headers.get('authorization') })), + }, { + first: { commitSha, content }, second: { commitSha, content }, apiBases: [endpoint], releasedClients: 0, + requests: [endpoint + paths[0], rawEndpoint + rawPath, endpoint + paths[0], rawEndpoint + rawPath].map(url => ({ url, authorization: null })), + }); + }); + + test('uses one API read and a commit-pinned raw download without credentials or content parsing', () => runWithFakedTimers({}, async () => { + const requests: Request[] = []; + const download = spy(GitHubTransport.prototype, 'anonymousDownload'); + const controller = new AbortController(); + const startedAt = Date.now(); + const service = create(async (input, init) => { + requests.push(new Request(input, init)); + if (requests.length === 1) { + await timeout(10); + return new Response(JSON.stringify({ sha: commitSha })); + } + return new Response(content); + }); + try { + const result = await read(service, controller.signal); + assert.deepStrictEqual({ + result, + apiBases: service.apiBases, + releasedClients: service.releasedClients, + reads: service.reads.map(read => ({ path: read.path, options: read.options, sameSignal: read.signal === controller.signal, aborted: read.signal.aborted })), + rawSignal: download.firstCall.args[3] === controller.signal, + requests: requests.map(request => ({ url: request.url, method: request.method, credentials: request.credentials, referrerPolicy: request.referrerPolicy, authorization: request.headers.get('authorization') })), + rawApiHeaders: ['x-github-api-version', 'x-client-application', 'x-client-source', 'x-client-feature', 'x-is-retry'].map(name => requests[1].headers.get(name)), + }, { + result: { commitSha, content }, + apiBases: [apiBaseUri], + releasedClients: 1, + reads: paths.map(path => ({ path, options: { deadline: startedAt + 5 * 60_000 }, sameSignal: true, aborted: false })), + rawSignal: true, + requests: [apiBaseUri + paths[0], rawBaseUri + rawPath].map(url => ({ url, method: 'GET', credentials: 'omit', referrerPolicy: 'no-referrer', authorization: null })), + rawApiHeaders: [null, null, null, null, null], + }); + } finally { + service.dispose(); + } + })); + + test('encodes raw file path segments without changing the pinned revision', async () => { + const requests: string[] = []; + const service = create(async input => { + requests.push(String(input)); + return fileResponse(input); + }); + const client = store.add(service.acquireAnonymousClient()).object; + const result = await client.getFile('microsoft', 'sample', 'docs/a #?%.md', CancellationToken.None); + assert.deepStrictEqual({ result, requests }, { + result: { commitSha, content }, + requests: [apiBaseUri + paths[0], `${rawBaseUri}/microsoft/sample/${commitSha}/docs/a%20%23%3F%25.md`], + }); + }); + + for (const invalidPath of ['', '..', '../main/file.json', '/microsoft/sample/main/file.json', '//other.example.test/file.json']) { + test(`rejects a path that escapes the pinned raw revision: ${JSON.stringify(invalidPath)}`, async () => { + const requests: string[] = []; + const service = create(async input => { + requests.push(String(input)); + return fileResponse(input); + }); + const client = store.add(service.acquireAnonymousClient()).object; + await assert.rejects(client.getFile('microsoft', 'sample', invalidPath, CancellationToken.None), { kind: 'validation' }); + assert.deepStrictEqual(requests, [apiBaseUri + paths[0]]); + }); + } + + test('getFile forwards caller-selected options and a shared deadline without mutating them', async () => { + const download = spy(GitHubTransport.prototype, 'anonymousDownload'); + const options = Object.freeze({ + caller: 'test.repositoryReader', + priority: 'background' as const, + deadline: Date.now() + 10_000, + etag: false, + }); + const service = create(async input => fileResponse(input)); + const result = await read(service, CancellationToken.None, options); + assert.deepStrictEqual({ + result, requests: service.reads.map(read => ({ path: read.path, options: read.options })), + rawRequest: download.firstCall.args[2], + }, { + result: { commitSha, content }, requests: paths.map(path => ({ path, options })), + rawRequest: { url: rawBaseUri + rawPath, maximumBytes: 1024 * 1024, timeout: 5 * 60_000, caller: options.caller, priority: options.priority, deadline: options.deadline }, + }); + }); + + test('getFile honors a caller deadline without fetching', async () => { + const service = create(async () => assert.fail('No fetch expected')); + await assert.rejects(read(service, CancellationToken.None, { deadline: Date.now() - 1 }), { kind: 'timeout' }); + assert.strictEqual(service.releasedClients, 1); + }); + + for (const [name, response] of [ + ['missing', undefined], ['null', null], ['array', []], ['missing SHA', {}], + ['non-string SHA', { sha: 123 }], ['short SHA', { sha: 'a'.repeat(39) }], + ['non-hex SHA', { sha: 'z'.repeat(40) }], + ['newline-suffixed SHA', { sha: `${commitSha}\n` }], + ] as const) { + test(`rejects ${name} commit responses before requesting contents`, async () => { + const service = create(async () => new Response(JSON.stringify(response))); + await assert.rejects(read(service), { kind: 'malformedResponse' }); + assert.deepStrictEqual({ paths: service.reads.map(read => read.path), released: service.releasedClients }, { paths: [paths[0]], released: 1 }); + }); + } + + for (const size of [0, 1024 * 1024]) { + test(`accepts a file of exactly ${size} bytes`, async () => { + const content = 'a'.repeat(size); + const service = create(async input => fileResponse(input, content)); + assert.deepStrictEqual(await read(service), { commitSha, content }); + }); + } + + test('preserves raw UTF-8 text, its byte order mark and whitespace', async () => { + const text = `\ufeff \t${content}\r\n `; + const service = create(async input => fileResponse(input, text)); + assert.deepStrictEqual(await read(service), { commitSha, content: text }); + }); + + test('bounds raw bytes rather than characters and cancels an oversized response', async () => { + const cancelled = new DeferredPromise(); + const bytes = new TextEncoder().encode('\u00e9'.repeat(512 * 1024 + 1)); + const service = create(async input => String(input).endsWith('/commits/HEAD') + ? new Response(JSON.stringify({ sha: commitSha })) + : new Response(new ReadableStream({ + start: controller => controller.enqueue(bytes), + cancel: () => { cancelled.complete(); }, + }))); + await assert.rejects(read(service), { kind: 'responseTooLarge' }); + await cancelled.p; + assert.strictEqual(service.releasedClients, 1); + }); + + test('a failed raw response body is not returned as a successful file', async () => { + const service = create(async input => String(input).endsWith('/commits/HEAD') + ? new Response(JSON.stringify({ sha: commitSha })) + : new Response(new ReadableStream({ start: controller => controller.error(new Error('Body failed')) }))); + await assert.rejects(read(service), { kind: 'network' }); + assert.strictEqual(service.releasedClients, 1); + }); + + for (const location of [ + 'https://other.example.test/file.json', + `http://raw.githubusercontent.com${rawPath}`, + `https://user@raw.githubusercontent.com${rawPath}`, + '/microsoft/sample/main/.devcontainer/devcontainer.json', + `/microsoft/other/${commitSha}/.devcontainer/devcontainer.json`, + ]) { + test(`rejects an unsafe or unpinned raw redirect: ${location}`, async () => { + const requests: Request[] = []; + const service = create(async (input, init) => { + requests.push(new Request(input, init)); + return String(input).endsWith('/commits/HEAD') + ? new Response(JSON.stringify({ sha: commitSha })) + : new Response(null, { status: 302, headers: { location } }); + }); + await assert.rejects(read(service), { kind: 'authorization' }); + assert.deepStrictEqual(requests.map(request => ({ url: request.url, credentials: request.credentials, authorization: request.headers.get('authorization') })), [ + { url: apiBaseUri + paths[0], credentials: 'omit', authorization: null }, + { url: rawBaseUri + rawPath, credentials: 'omit', authorization: null }, + ]); + }); + } + + test('raw redirects within the pinned revision remain credential-free', async () => { + const requests: Request[] = []; + const redirectedPath = `/microsoft/sample/${commitSha}/.devcontainer/renamed.json`; + const service = create(async (input, init) => { + requests.push(new Request(input, init)); + return String(input).endsWith('/commits/HEAD') + ? new Response(JSON.stringify({ sha: commitSha })) + : requests.length === 2 ? new Response(null, { status: 302, headers: { location: redirectedPath } }) + : new Response(content); + }); + const result = await read(service); + assert.deepStrictEqual({ + result, requests: requests.map(request => ({ url: request.url, credentials: request.credentials, referrerPolicy: request.referrerPolicy, authorization: request.headers.get('authorization') })), + }, { + result: { commitSha, content }, + requests: [apiBaseUri + paths[0], rawBaseUri + rawPath, rawBaseUri + redirectedPath].map(url => ({ + url, credentials: 'omit', referrerPolicy: 'no-referrer', authorization: null, + })), + }); + }); + + test('raw downloads do not wait on an exhausted REST API quota', async () => { + const requests: string[] = []; + const service = create(async input => { + requests.push(String(input)); + return String(input).endsWith('/commits/HEAD') + ? new Response(JSON.stringify({ sha: commitSha }), { headers: { 'X-RateLimit-Remaining': '0', 'X-RateLimit-Reset': String(Math.floor(Date.now() / 1000) + 60) } }) + : new Response(content); + }); + const result = await read(service, CancellationToken.None, { deadline: Date.now() + 1000 }); + assert.deepStrictEqual({ result, requests }, { result: { commitSha, content }, requests: [apiBaseUri + paths[0], rawBaseUri + rawPath] }); + }); + + test('raw cooldowns survive client release without blocking API reads', () => runWithFakedTimers({}, async () => { + const requests: string[] = []; + let rawRequests = 0; + const service = create(async input => { + const url = String(input); + requests.push(url); + if (url.startsWith(rawBaseUri)) { + return ++rawRequests === 1 ? new Response(null, { status: 429, headers: { 'Retry-After': '60' } }) : new Response(content); + } + return new Response(JSON.stringify({ sha: commitSha })); + }); + try { + await assert.rejects(read(service), { kind: 'rateLimit', statusCode: 429 }); + const client = store.add(service.acquireAnonymousClient()).object; + await client.get('/probe', CancellationToken.None); + const pending = assert.rejects(client.getFile('microsoft', 'sample', path, CancellationToken.None, { deadline: Date.now() + 50 }), { kind: 'timeout' }); + await timeout(50); + await pending; + assert.deepStrictEqual(requests, [apiBaseUri + paths[0], rawBaseUri + rawPath, `${apiBaseUri}/probe`, apiBaseUri + paths[0]]); + } finally { + service.dispose(); + } + })); + + test('propagates raw HTTP errors without falling back to the API and releases the lease', async () => { + const service = create(async input => String(input).endsWith('/commits/HEAD') + ? new Response(JSON.stringify({ sha: commitSha })) + : new Response('{}', { status: 404 })); + await assert.rejects(read(service), { kind: 'notFound', statusCode: 404 }); + assert.deepStrictEqual({ paths: service.reads.map(read => read.path), released: service.releasedClients }, { paths, released: 1 }); + }); + + for (const [name, reason] of [['default', undefined], ['custom', new Error('Caller cancelled')], ['non-error', { cancelled: true }]] as const) { + test(`cancellation before reading preserves the ${name} abort reason without requests`, async () => { + const service = create(async () => { throw new Error('No fetch expected'); }); + const controller = new AbortController(); + controller.abort(reason); + await assert.rejects(read(service, controller.signal), error => error === controller.signal.reason); + assert.deepStrictEqual({ apiBases: service.apiBases, reads: service.reads, released: service.releasedClients }, { apiBases: [apiBaseUri], reads: [], released: 1 }); + }); + } + + test('a cancelled token performs no reads', async () => { + const service = create(async () => assert.fail('No fetch expected')); + await assert.rejects(read(service, CancellationToken.Cancelled), CancellationError); + assert.deepStrictEqual({ apiBases: service.apiBases, reads: service.reads, released: service.releasedClients }, { apiBases: [apiBaseUri], reads: [], released: 1 }); + }); + + for (const kind of ['signal', 'token'] as const) { + for (const cancelledRead of [1, 2]) { + test(`${kind} cancels read ${cancelledRead}, preserves the abort reason and releases the lease`, async () => { + const controller = new AbortController(); + const token = store.add(new CancellationTokenSource()); + const reason = new Error('Caller cancelled'); + const started = new DeferredPromise(); + let calls = 0; + const service = create(async (_input, init) => { + if (++calls !== cancelledRead) { + return new Response(JSON.stringify({ sha: commitSha })); + } + assert.ok(init?.signal); + const signal = init.signal; + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + started.complete(signal); + }); + }); + const rejected = assert.rejects(read(service, kind === 'signal' ? controller.signal : token.token), + error => kind === 'signal' ? error === reason : error instanceof CancellationError); + const signal = await started.p; + if (kind === 'signal') { + controller.abort(reason); + } else { + token.cancel(); + } + await rejected; + assert.deepStrictEqual({ calls, aborted: signal.aborted, released: service.releasedClients }, { calls: cancelledRead, aborted: true, released: 1 }); + }); + } + } + + for (const fails of [false, true]) { + test(`releases the token adapter and lease when the repository read settles (fails: ${fails})`, async () => { + const download = spy(GitHubTransport.prototype, 'anonymousDownload'); + const token = store.add(new CancellationTokenSource()); + const service = create(async input => String(input).endsWith('/commits/HEAD') + ? new Response(JSON.stringify({ sha: commitSha })) + : new Response(content, { status: fails ? 404 : 200 })); + if (fails) { + await assert.rejects(read(service, token.token), { kind: 'notFound' }); + } else { + assert.deepStrictEqual(await read(service, token.token), { commitSha, content }); + } + token.cancel(); + assert.deepStrictEqual({ + paths: service.reads.map(read => read.path), aborted: service.reads.map(read => read.signal.aborted), + sameSignal: service.reads[0].signal === download.firstCall.args[3], released: service.releasedClients, + }, { paths, aborted: [false], sameSignal: true, released: 1 }); + }); + } + + test('releases the token listener when the client has already been disposed', async () => { + const cancelled = store.add(new Emitter()); + const service = create(async () => assert.fail('No fetch expected')); + const client = store.add(service.acquireAnonymousClient({ apiBaseUri })); + client.dispose(); + await assert.rejects(client.object.getFile('microsoft', 'sample', path, { + isCancellationRequested: false, onCancellationRequested: cancelled.event, + }), { kind: 'unknown', message: 'GitHub client was disposed' }); + assert.strictEqual(cancelled.hasListeners(), false); + }); + + test('cancelling one repository read and releasing its lease preserves a live peer', async () => { + const started = new DeferredPromise(); + const commitResponse = new DeferredPromise(); + let attempts = 0; + const service = create(async (input, init) => { + attempts++; + if (String(input).endsWith('/commits/HEAD')) { + assert.ok(init?.signal); + started.complete(init.signal); + return commitResponse.p; + } + return new Response(content); + }); + const first = store.add(service.acquireAnonymousClient({ apiBaseUri })); + const second = store.add(service.acquireAnonymousClient({ apiBaseUri })); + const controller = new AbortController(); + const reason = new Error('Caller cancelled'); + const rejected = assert.rejects(first.object.getFile('microsoft', 'sample', path, controller.signal), error => error === reason); + const peer = second.object.getFile('microsoft', 'sample', path, CancellationToken.None); + const signal = await started.p; + controller.abort(reason); + first.dispose(); + await rejected; + await commitResponse.complete(new Response(JSON.stringify({ sha: commitSha }))); + const result = await peer; + assert.deepStrictEqual({ + result, attempts, aborted: signal.aborted, releasedClients: service.releasedClients, + }, { result: { commitSha, content }, attempts: 2, aborted: false, releasedClients: 1 }); + }); + + test('disposing the owning service aborts file reads and releases the lease', async () => { + const controller = new AbortController(); + const started = new DeferredPromise(); + const service = create(async (_input, init) => { + assert.ok(init?.signal); + const signal = init.signal; + await started.complete(signal); + return new Promise((_resolve, reject) => signal.addEventListener('abort', () => reject(signal.reason), { once: true })); + }); + const rejected = assert.rejects(read(service, controller.signal)); + const signal = await started.p; + service.dispose(); + await rejected; + assert.deepStrictEqual({ aborted: signal.aborted, callerAborted: controller.signal.aborted, released: service.releasedClients }, { aborted: true, callerAborted: false, released: 1 }); + }); + + test('the second read expires at the original operation deadline', () => runWithFakedTimers({}, async () => { + const download = spy(GitHubTransport.prototype, 'anonymousDownload'); + const startedAt = Date.now(); + const token = store.add(new CancellationTokenSource()); + let calls = 0; + const service = create(async (_input, init) => { + if (++calls === 1) { + await timeout(60_000); + return new Response(JSON.stringify({ sha: commitSha })); + } + assert.ok(init?.signal); + const signal = init.signal; + return new Promise((_resolve, reject) => signal.addEventListener('abort', () => reject(signal.reason), { once: true })); + }); + try { + const rejected = assert.rejects(read(service, token.token), { kind: 'timeout' }); + await timeout(5 * 60_000); + await rejected; + token.cancel(); + assert.deepStrictEqual({ + calls, released: service.releasedClients, deadlines: service.reads.map(read => read.options?.deadline), + rawDeadline: download.firstCall.args[2].deadline, + aborted: service.reads.map(read => read.signal.aborted), elapsed: Date.now() - startedAt, + }, { calls: 2, released: 1, deadlines: [startedAt + 5 * 60_000], rawDeadline: startedAt + 5 * 60_000, aborted: [false], elapsed: 5 * 60_000 }); + } finally { + service.dispose(); + } + })); +}); diff --git a/src/vs/platform/github/test/common/cancellation.test.ts b/src/vs/platform/github/test/common/cancellation.test.ts new file mode 100644 index 00000000000000..35283d8d4976ee --- /dev/null +++ b/src/vs/platform/github/test/common/cancellation.test.ts @@ -0,0 +1,80 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { CancellationError } from '../../../../base/common/errors.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { toAbortSignal } from '../../common/cancellation.js'; + +suite('GitHub cancellation', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('preserves an AbortSignal and its abort reason without taking ownership', () => { + const lifetime = store.add(new DisposableStore()); + const controller = new AbortController(); + const signal = toAbortSignal(controller.signal, lifetime); + lifetime.dispose(); + const abortedByDisposal = signal.aborted; + const reason = { cancelled: true }; + controller.abort(reason); + assert.deepStrictEqual({ + sameSignal: signal === controller.signal, abortedByDisposal, reason: signal.reason, + }, { sameSignal: true, abortedByDisposal: false, reason }); + }); + + test('preserves an already aborted signal', () => { + const lifetime = store.add(new DisposableStore()); + const controller = new AbortController(); + const reason = new Error('Caller cancelled'); + controller.abort(reason); + const signal = toAbortSignal(controller.signal, lifetime); + function isExpectedError(error: unknown): boolean { + return error === reason; + } + assert.strictEqual(signal, controller.signal); + assert.throws(() => signal.throwIfAborted(), isExpectedError); + }); + + test('converts token cancellation to a CancellationError', () => { + const lifetime = store.add(new DisposableStore()); + const token = store.add(new CancellationTokenSource()); + const signal = toAbortSignal(token.token, lifetime); + const initiallyAborted = signal.aborted; + token.cancel(); + assert.deepStrictEqual({ + initiallyAborted, aborted: signal.aborted, cancellationError: signal.reason instanceof CancellationError, + }, { initiallyAborted: false, aborted: true, cancellationError: true }); + }); + + test('an already cancelled token does not register a listener', () => { + const lifetime = store.add(new DisposableStore()); + const signal = toAbortSignal({ + isCancellationRequested: true, + onCancellationRequested: () => assert.fail('No listener expected for a cancelled token'), + }, lifetime); + assert.throws(() => signal.throwIfAborted(), CancellationError); + }); + + test('accepts CancellationToken.None', () => { + const lifetime = store.add(new DisposableStore()); + const signal = toAbortSignal(CancellationToken.None, lifetime); + assert.strictEqual(signal.aborted, false); + }); + + test('detaches the token listener when the operation lifetime ends', () => { + const lifetime = store.add(new DisposableStore()); + const cancelled = store.add(new Emitter()); + const signal = toAbortSignal({ isCancellationRequested: false, onCancellationRequested: cancelled.event }, lifetime); + const listening = cancelled.hasListeners(); + lifetime.dispose(); + cancelled.fire(); + assert.deepStrictEqual({ + listening, listeningAfterDisposal: cancelled.hasListeners(), aborted: signal.aborted, + }, { listening: true, listeningAfterDisposal: false, aborted: false }); + }); +}); diff --git a/src/vs/platform/github/test/common/githubAnonymousClient.test.ts b/src/vs/platform/github/test/common/githubAnonymousClient.test.ts deleted file mode 100644 index 7ce0882c714cec..00000000000000 --- a/src/vs/platform/github/test/common/githubAnonymousClient.test.ts +++ /dev/null @@ -1,389 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { DeferredPromise, timeout } from '../../../../base/common/async.js'; -import { Emitter, Event } from '../../../../base/common/event.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; -import { NullLogService } from '../../../log/common/log.js'; -import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; -import { GitHubService } from '../../common/githubService.js'; -import { GitHubClientOptions, GitHubCredentialChange, GitHubRequestError, GitHubServiceOptions } from '../../common/githubTypes.js'; - -const apiBaseUri = 'https://api.github.com'; -const account = { host: 'api.github.com', accountId: '101' }; -const authorizedOptions: GitHubClientOptions = { - apiBaseUri, graphQlUri: `${apiBaseUri}/graphql`, - authorization: { providerId: 'github', sessionId: 'session', scopes: ['repo'] }, -}; - -suite('GitHub anonymous clients', () => { - const store = ensureNoDisposablesAreLeakedInTestSuite(); - const signal = () => new AbortController().signal; - const create = (options: GitHubServiceOptions) => store.add(new GitHubService(options, new NullLogService(), NullTelemetryService)); - - function assertRequestError(action: () => void, kind: GitHubRequestError['kind']): void { - function isExpectedError(error: unknown): boolean { - return error instanceof GitHubRequestError && error.kind === kind; - } - assert.throws(action, isExpectedError); - } - - test('public reads require no credential provider, identity lookup or ambient credentials', async () => { - const seen: { url: string; authorization: string | null; credentials: RequestCredentials | undefined; referrerPolicy: ReferrerPolicy | undefined; method: string | undefined; body: RequestInit['body'] }[] = []; - const service = create({ - fetch: async (input, init) => { - seen.push({ - url: String(input), authorization: new Headers(init?.headers).get('Authorization'), - credentials: init?.credentials, referrerPolicy: init?.referrerPolicy, method: init?.method, body: init?.body, - }); - return new Response('{"items":[]}'); - }, - }); - const client = store.add(service.acquireAnonymousClient({ apiBaseUri })).object; - const response = await client.get('/search/issues?q=test', signal()); - assert.deepStrictEqual({ authorization: client.authorization, response: response.data, seen }, { - authorization: { kind: 'anonymous' }, - response: { items: [] }, - seen: [{ url: `${apiBaseUri}/search/issues?q=test`, authorization: null, credentials: 'omit', referrerPolicy: 'no-referrer', method: 'GET', body: undefined }], - }); - assertRequestError(() => service.acquireClient(authorizedOptions), 'authentication'); - }); - - test('anonymous clients share an origin without being invalidated by sign-out', async () => { - const changed = store.add(new Emitter()); - const service = create({ - credentialProvider: { onDidChange: changed.event, getToken: () => { throw new Error('Anonymous calls must not request a token'); } }, - fetch: async () => new Response('{"public":true}'), - }); - const first = store.add(service.acquireAnonymousClient({ apiBaseUri })); - const peer = store.add(service.acquireAnonymousClient({ apiBaseUri: 'https://API.GITHUB.COM/' })); - first.dispose(); - changed.fire({ providerId: 'github' }); - const response = await peer.object.get('/repos/owner/repo', signal()); - assert.deepStrictEqual({ shared: first.object === peer.object, response: response.data }, { shared: true, response: { public: true } }); - }); - - test('private ETags and responses cannot enter the anonymous cache', async () => { - const seen: { authorization: boolean; etag: string | null }[] = []; - const service = create({ - credentialProvider: { onDidChange: Event.None, getToken: () => 'token' }, - fetch: async (_input, init) => { - const headers = new Headers(init?.headers); - const authorization = headers.has('Authorization'); - const etag = headers.get('If-None-Match'); - seen.push({ authorization, etag }); - return etag - ? new Response(null, { status: 304 }) - : new Response(JSON.stringify({ visibility: authorization ? 'private' : 'public' }), { headers: { ETag: authorization ? '"private"' : '"public"' } }); - }, - }); - const authorized = store.add(service.acquireClient(authorizedOptions)).object; - const anonymous = store.add(service.acquireAnonymousClient({ apiBaseUri })).object; - const url = `${apiBaseUri}/repos/owner/repo`; - await authorized.transport.rest(account, 'token', { method: 'GET', url }, signal()); - await anonymous.get('/repos/owner/repo', signal()); - const reused = await anonymous.get('/repos/owner/repo', signal()); - assert.deepStrictEqual({ seen, publicData: reused.data }, { - seen: [{ authorization: true, etag: null }, { authorization: false, etag: null }, { authorization: false, etag: '"public"' }], - publicData: { visibility: 'public' }, - }); - }); - - test('anonymous reads coalesce and cancellation preserves a live peer', async () => { - const started = new DeferredPromise(); - const response = new DeferredPromise(); - let calls = 0; - const service = create({ - fetch: async (_input, init) => { - calls++; - assert.ok(init?.signal); - await started.complete(init.signal); - return response.p; - }, - }); - const first = store.add(service.acquireAnonymousClient({ apiBaseUri })); - const second = store.add(service.acquireAnonymousClient({ apiBaseUri })); - const controller = new AbortController(); - const reason = new Error('cancelled'); - const rejected = assert.rejects(first.object.get('/repos/owner/repo', controller.signal), error => error === reason); - const peer = second.object.get('/repos/owner/repo', signal()); - const active = await started.p; - controller.abort(reason); - first.dispose(); - await rejected; - await response.complete(new Response('{"ok":true}')); - await peer; - assert.deepStrictEqual({ calls, aborted: active.aborted }, { calls: 1, aborted: false }); - }); - - test('last-reference disposal stops only anonymous work and later reads can reacquire', async () => { - const started = new DeferredPromise(); - let calls = 0; - const service = create({ - fetch: async (_input, init) => { - if (++calls === 1) { - assert.ok(init?.signal); - const signal = init.signal; - await started.complete(signal); - return new Promise((_resolve, reject) => signal.addEventListener('abort', () => reject(signal.reason), { once: true })); - } - return new Response('{}'); - }, - }); - const reference = store.add(service.acquireAnonymousClient({ apiBaseUri })); - const pending = assert.rejects(reference.object.get('/repos/owner/repo', signal())); - const active = await started.p; - reference.dispose(); - await pending; - await assert.rejects(reference.object.get('/repos/owner/repo', signal()), /disposed/); - const replacement = store.add(service.acquireAnonymousClient({ apiBaseUri })).object; - await replacement.get('/repos/owner/repo', signal()); - assert.deepStrictEqual({ aborted: active.aborted, replaced: replacement !== reference.object, calls }, { aborted: true, replaced: true, calls: 2 }); - }); - - for (const mode of ['shared-origin', 'reacquired'] as const) { - test(`anonymous cooldown survives ${mode} while authenticated traffic remains independent`, () => runWithFakedTimers({}, async () => { - const started = Date.now(); - const requests: { url: string; at: number; authenticated: boolean }[] = []; - const service = create({ - credentialProvider: { onDidChange: Event.None, getToken: () => 'token' }, - fetch: async (input, init) => { - requests.push({ url: String(input), at: Date.now() - started, authenticated: new Headers(init?.headers).has('Authorization') }); - return requests.length === 1 ? new Response('{"message":"secondary rate limit"}', { status: 403, headers: { 'Retry-After': '5' } }) : new Response('{}'); - }, - }); - try { - const first = store.add(service.acquireAnonymousClient({ apiBaseUri })); - await assert.rejects(first.object.get('/repos/owner/repo', signal()), { kind: 'rateLimit' }); - if (mode === 'reacquired') { - first.dispose(); - } - const second = store.add(service.acquireAnonymousClient({ apiBaseUri: mode === 'shared-origin' ? `${apiBaseUri}/api/v3` : apiBaseUri })); - const authorized = store.add(service.acquireClient(authorizedOptions)).object; - await authorized.transport.rest(account, 'token', { method: 'GET', url: `${apiBaseUri}/repos/owner/other` }, signal()); - await second.object.get('/search/issues?q=test', signal()); - assert.deepStrictEqual(requests, [ - { url: `${apiBaseUri}/repos/owner/repo`, at: 0, authenticated: false }, - { url: `${apiBaseUri}/repos/owner/other`, at: 0, authenticated: true }, - { url: `${second.object.apiBaseUri}/search/issues?q=test`, at: 5_000, authenticated: false }, - ]); - } finally { - service.dispose(); - } - })); - } - - test('authenticated failures never trigger anonymous fallback or borrow another credential', async () => { - const requests: boolean[] = []; - const service = create({ - credentialProvider: { onDidChange: Event.None, getToken: () => { throw new Error('Unexpected credential selection'); } }, - fetch: async (_input, init) => { - requests.push(new Headers(init?.headers).has('Authorization')); - return new Response('{"message":"Not Found"}', { status: 404 }); - }, - }); - const authorized = store.add(service.acquireClient(authorizedOptions)).object; - const anonymous = store.add(service.acquireAnonymousClient({ apiBaseUri })).object; - await assert.rejects(authorized.transport.rest(account, 'token', { method: 'GET', url: `${apiBaseUri}/private` }, signal()), { kind: 'notFound' }); - await assert.rejects(anonymous.get('/private', signal()), { kind: 'notFound' }); - assert.deepStrictEqual(requests, [true, false]); - }); - - test('anonymous and authenticated contexts share the engine client-capacity limit', () => { - const service = create({ credentialProvider: { onDidChange: Event.None, getToken: () => 'token' } }); - for (let i = 0; i < 63; i++) { - store.add(service.acquireClient({ ...authorizedOptions, authorization: { ...authorizedOptions.authorization, sessionId: String(i) } })); - } - const publicClient = store.add(service.acquireAnonymousClient({ apiBaseUri })); - assertRequestError(() => service.acquireAnonymousClient({ apiBaseUri: 'https://other.example.test' }), 'overloaded'); - publicClient.dispose(); - const replacement = store.add(service.acquireAnonymousClient({ apiBaseUri: 'https://other.example.test' })); - assert.strictEqual(replacement.object.apiBaseUri, 'https://other.example.test'); - }); - - test('rejects unsafe endpoints and paths before invoking fetch', async () => { - let calls = 0; - const service = create({ fetch: async () => { calls++; return new Response('{}'); } }); - for (const endpoint of ['not a URL', 'http://api.github.com', 'https://name:password@api.github.com', 'https://api.github.com?secret=value', 'https://api.github.com#fragment']) { - assertRequestError(() => service.acquireAnonymousClient({ apiBaseUri: endpoint }), 'validation'); - } - const client = store.add(service.acquireAnonymousClient({ apiBaseUri: 'https://enterprise.example.test/api/v3' })).object; - for (const path of ['https://other.example.test', '//other.example.test', '/../../outside', '/%2e%2e/%2e%2e/outside', '/repos\\owner', '/repos#fragment']) { - await assert.rejects(client.get(path, signal()), { kind: 'validation' }); - } - assert.strictEqual(calls, 0); - }); - - test('redirects never attach credentials and cannot change origin', async () => { - const requests: { url: string; credentialless: boolean }[] = []; - const service = create({ - fetch: async (input, init) => { - requests.push({ url: String(input), credentialless: init?.credentials === 'omit' && !new Headers(init.headers).has('Authorization') }); - return new Response(null, { status: 302, headers: { Location: requests.length === 1 ? '/renamed' : 'https://other.example.test/data' } }); - }, - }); - const client = store.add(service.acquireAnonymousClient({ apiBaseUri })).object; - await assert.rejects(client.get('/old', signal()), { kind: 'authorization' }); - assert.deepStrictEqual(requests, [{ url: `${apiBaseUri}/old`, credentialless: true }, { url: `${apiBaseUri}/renamed`, credentialless: true }]); - }); - - for (const location of ['/outside', '../outside', '/api/v30/outside', '/api/v3/../outside', '/api/v3/%2e%2e/outside', '/api/v3']) { - test(`rejects anonymous redirects outside the API base: ${location}`, async () => { - const baseUri = 'https://enterprise.example.test/api/v3'; - const requests: string[] = []; - const service = create({ - fetch: async input => { - requests.push(String(input)); - return String(input) === `${baseUri}/old` - ? new Response(null, { status: 302, headers: { Location: location } }) - : new Response('{"outside":true}', { headers: { ETag: '"outside"' } }); - }, - }); - const client = store.add(service.acquireAnonymousClient({ apiBaseUri: `${baseUri}///` })).object; - await assert.rejects(client.get('/old', signal()), { kind: 'authorization' }); - await assert.rejects(client.get('/old', signal()), { kind: 'authorization' }); - assert.deepStrictEqual(requests, [`${baseUri}/old`, `${baseUri}/old`]); - }); - } - - test('anonymous redirects within the normalized API base retain conditional caching', async () => { - const baseUri = 'https://enterprise.example.test/api/v3'; - const requests: { url: string; etag: string | null }[] = []; - const service = create({ - fetch: async (input, init) => { - const etag = new Headers(init?.headers).get('If-None-Match'); - requests.push({ url: String(input), etag }); - if (String(input) === `${baseUri}/old`) { - return new Response(null, { status: 301, headers: { Location: 'renamed' } }); - } - return etag - ? new Response(null, { status: 304 }) - : new Response('{"public":true}', { headers: { ETag: '"public"' } }); - }, - }); - const client = store.add(service.acquireAnonymousClient({ apiBaseUri: 'https://enterprise.example.test/ignored/../api/v3///' })).object; - await client.get('/old', signal()); - const reused = await client.get('/old', signal()); - assert.deepStrictEqual({ requests, statusCode: reused.statusCode, data: reused.data, finalUrl: reused.finalUrl }, { - requests: [ - { url: `${baseUri}/old`, etag: null }, - { url: `${baseUri}/renamed`, etag: null }, - { url: `${baseUri}/renamed`, etag: '"public"' }, - ], - statusCode: 304, data: { public: true }, finalUrl: `${baseUri}/renamed`, - }); - }); - - test('a cached anonymous redirect cannot redirect outside the API base', async () => { - const baseUri = 'https://enterprise.example.test/api/v3'; - const requests: string[] = []; - const service = create({ - fetch: async input => { - requests.push(String(input)); - if (String(input) === `${baseUri}/old`) { - return new Response(null, { status: 302, headers: { Location: '/api/v3/renamed' } }); - } - return requests.length === 3 - ? new Response(null, { status: 307, headers: { Location: '/outside' } }) - : new Response('{"public":true}', { headers: { ETag: '"public"' } }); - }, - }); - const client = store.add(service.acquireAnonymousClient({ apiBaseUri: baseUri })).object; - await client.get('/old', signal()); - await assert.rejects(client.get('/old', signal()), { kind: 'authorization' }); - assert.deepStrictEqual(requests, [`${baseUri}/old`, `${baseUri}/renamed`, `${baseUri}/renamed`]); - }); - - test('anonymous API path boundaries do not restrict authenticated redirects', async () => { - const baseUri = 'https://enterprise.example.test/api/v3'; - const requests: string[] = []; - const service = create({ - credentialProvider: { onDidChange: Event.None, getToken: () => 'token' }, - fetch: async input => { - requests.push(String(input)); - return requests.length === 1 - ? new Response(null, { status: 302, headers: { Location: '/outside' } }) - : new Response('{"authenticated":true}'); - }, - }); - const client = store.add(service.acquireClient({ ...authorizedOptions, apiBaseUri: baseUri, graphQlUri: 'https://enterprise.example.test/api/graphql' })).object; - const response = await client.transport.rest({ host: 'enterprise.example.test', accountId: '101' }, 'token', { method: 'GET', url: `${baseUri}/old` }, signal()); - assert.deepStrictEqual({ requests, data: response.data }, { - requests: [`${baseUri}/old`, 'https://enterprise.example.test/outside'], data: { authenticated: true }, - }); - }); - - test('anonymous work participates in the same host concurrency bound as authenticated work', async () => { - const gates = Array.from({ length: 3 }, () => new DeferredPromise()); - const requests: boolean[] = []; - let active = 0; - let highWater = 0; - const service = create({ - credentialProvider: { onDidChange: Event.None, getToken: () => 'token' }, - fetch: async (_input, init) => { - const index = requests.length; - requests.push(new Headers(init?.headers).has('Authorization')); - highWater = Math.max(highWater, ++active); - try { - return await gates[index].p; - } finally { - active--; - } - }, - }); - const anonymous = store.add(service.acquireAnonymousClient({ apiBaseUri })).object; - const authenticated = store.add(service.acquireClient(authorizedOptions)).object; - const operations = [ - anonymous.get('/repos/owner/public', signal(), { caller: 'public' }), - authenticated.transport.rest(account, 'token', { method: 'GET', url: `${apiBaseUri}/repos/owner/first`, caller: 'first' }, signal()), - authenticated.transport.rest({ ...account, accountId: '202' }, 'other-token', { method: 'GET', url: `${apiBaseUri}/repos/owner/second`, caller: 'second' }, signal()), - ]; - await timeout(0); - const initiallyStarted = requests.length; - for (const gate of gates) { - await gate.complete(new Response('{}')); - } - await Promise.all(operations); - assert.deepStrictEqual({ initiallyStarted, highWater, requests }, { initiallyStarted: 2, highWater: 2, requests: [false, true, true] }); - }); - - test('different API paths on the same origin cannot multiply anonymous concurrency', async () => { - const gate = new DeferredPromise(); - const requests: string[] = []; - const service = create({ - fetch: async input => { - requests.push(String(input)); - return requests.length === 1 ? gate.p : new Response('{}'); - }, - }); - const first = store.add(service.acquireAnonymousClient({ apiBaseUri })).object; - const otherPath = store.add(service.acquireAnonymousClient({ apiBaseUri: `${apiBaseUri}/api/v3` })).object; - const operations = [first.get('/first', signal()), otherPath.get('/second', signal())]; - await timeout(0); - const beforeRelease = requests.length; - await gate.complete(new Response('{}')); - await Promise.all(operations); - assert.deepStrictEqual({ beforeRelease, requests }, { - beforeRelease: 1, requests: [`${apiBaseUri}/first`, `${apiBaseUri}/api/v3/second`], - }); - }); - - test('anonymous work respects deadlines while parked by a server cooldown', () => runWithFakedTimers({}, async () => { - let calls = 0; - const service = create({ fetch: async () => { calls++; return new Response(null, { status: 429, headers: { 'Retry-After': '60' } }); } }); - try { - const client = store.add(service.acquireAnonymousClient({ apiBaseUri })).object; - await assert.rejects(client.get('/repos/owner/repo', signal()), { kind: 'rateLimit' }); - const pending = assert.rejects(client.get('/repos/owner/other', signal(), { deadline: Date.now() + 50 }), { kind: 'timeout' }); - await timeout(50); - await pending; - assert.strictEqual(calls, 1); - } finally { - service.dispose(); - } - })); -}); diff --git a/src/vs/platform/github/test/common/githubEndpoints.test.ts b/src/vs/platform/github/test/common/githubEndpoints.test.ts index ed6e2924e27f3e..4189639f9cfc8c 100644 --- a/src/vs/platform/github/test/common/githubEndpoints.test.ts +++ b/src/vs/platform/github/test/common/githubEndpoints.test.ts @@ -12,6 +12,7 @@ suite('githubEndpoints', () => { const DOT_COM = { apiBaseUri: 'https://api.github.com', + rawBaseUri: 'https://raw.githubusercontent.com', graphQlUri: 'https://api.github.com/graphql', oauthServer: 'https://github.com/login/oauth', enterpriseHost: undefined, diff --git a/src/vs/platform/github/test/common/githubIpc.test.ts b/src/vs/platform/github/test/common/githubIpc.test.ts index 6bf85231889586..260e7a13df7df5 100644 --- a/src/vs/platform/github/test/common/githubIpc.test.ts +++ b/src/vs/platform/github/test/common/githubIpc.test.ts @@ -85,6 +85,7 @@ suite('Shared-process GitHub channel', () => { object: { apiBaseUri: request.apiBaseUri, authorization: { kind: 'anonymous' }, + getFile: async () => assert.fail('Not a repository file request'), get: async () => { throw error; }, }, dispose: () => { released++; }, diff --git a/src/vs/platform/github/test/common/githubResponse.test.ts b/src/vs/platform/github/test/common/githubResponse.test.ts index dc5b98a2c1aebb..2f41d924cd98f9 100644 --- a/src/vs/platform/github/test/common/githubResponse.test.ts +++ b/src/vs/platform/github/test/common/githubResponse.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { arrayProperty, asArray, asObject, booleanProperty, idProperty, nextLink, normalizedEnumProperty, nullableStringProperty, numberProperty, objectAt, objectProperty, optionalObjectProperty, requiredId, requiredNumber, requiredString, stringProperty } from '../../common/githubResponse.js'; +import { arrayProperty, asArray, asObject, booleanProperty, idProperty, nextLink, normalizedEnumProperty, nullableStringProperty, numberProperty, objectAt, objectProperty, optionalObjectProperty, requiredId, requiredNumber, requiredSha, requiredString, stringProperty } from '../../common/githubResponse.js'; import { GitHubRequestError } from '../../common/githubTransport.js'; suite('GitHub response helpers', () => { @@ -129,6 +129,8 @@ suite('GitHub response helpers', () => { { name: 'a missing required string', read: () => requiredString({}, 'title'), message: 'GitHub response property title was not a string' }, { name: 'a null required string', read: () => requiredString({ title: null }, 'title'), message: 'GitHub response property title was not a string' }, { name: 'a non-string property', read: () => requiredString({ title: 7 }, 'title'), message: 'GitHub response property title was not a string' }, + { name: 'a missing required SHA', read: () => requiredSha({}, 'sha'), message: 'GitHub response property sha was not a string' }, + { name: 'a non-string SHA', read: () => requiredSha({ sha: 7 }, 'sha'), message: 'GitHub response property sha was not a string' }, { name: 'a missing required number', read: () => requiredNumber({}, 'number'), message: 'GitHub response property number was not a number' }, { name: 'a non-number property', read: () => requiredNumber({ number: '7' }, 'number'), message: 'GitHub response property number was not a number' }, { name: 'a non-finite number', read: () => requiredNumber({ number: Infinity }, 'number'), message: 'GitHub response property number was not a number' }, @@ -166,6 +168,24 @@ suite('GitHub response helpers', () => { }); }); + test('requiredSha preserves lowercase hexadecimal SHAs', () => { + const values = ['0'.repeat(40), 'f'.repeat(40), '0123456789abcdef0123456789abcdef01234567']; + assert.deepStrictEqual(values.map(sha => requiredSha({ sha }, 'sha')), values); + }); + + for (const [name, sha] of [ + ['empty', ''], + ['short', 'a'.repeat(39)], + ['long', 'a'.repeat(41)], + ['non-hexadecimal', 'g'.repeat(40)], + ['uppercase', 'A'.repeat(40)], + ['newline-suffixed', `${'a'.repeat(40)}\n`], + ] as const) { + test(`requiredSha rejects ${name} values`, () => { + assertMalformedResponse(() => requiredSha({ sha }, 'sha'), 'GitHub response property sha was not a valid SHA'); + }); + } + test('number readers accept finite values without coercion', () => { const values = [0, -7, 1.5, undefined, null, '7', true, NaN, Infinity, -Infinity]; assert.deepStrictEqual({ diff --git a/src/vs/platform/github/test/common/githubUrls.test.ts b/src/vs/platform/github/test/common/githubUrls.test.ts index 3a2ba7933fdcd9..5229c236bb648d 100644 --- a/src/vs/platform/github/test/common/githubUrls.test.ts +++ b/src/vs/platform/github/test/common/githubUrls.test.ts @@ -6,11 +6,35 @@ import assert from 'assert'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { getPullRequestUrlKey, parseGitHubCommitTarget, parseGitHubIssueUrl, parseGitHubLinkTarget, parseGitHubPullRequestUrl, parsePullRequestUrl } from '../../common/githubUrls.js'; +import { encodePathSegments, getPullRequestUrlKey, parseGitHubCommitTarget, parseGitHubIssueUrl, parseGitHubLinkTarget, parseGitHubPullRequestUrl, parsePullRequestUrl } from '../../common/githubUrls.js'; -suite('GitHub URL parsers', () => { +suite('GitHub URL helpers', () => { ensureNoDisposablesAreLeakedInTestSuite(); + test('path encoding escapes each segment without changing separators', () => { + assert.deepStrictEqual([ + '', + 'src/file.ts', + '/src//file.ts/', + 'docs/a #?%.md', + 'literal%2Fname.txt', + 'a\\b.txt', + 'caf\u00e9/\u65e5\u672c.md', + ].map(encodePathSegments), [ + '', + 'src/file.ts', + '/src//file.ts/', + 'docs/a%20%23%3F%25.md', + 'literal%252Fname.txt', + 'a%5Cb.txt', + 'caf%C3%A9/%E6%97%A5%E6%9C%AC.md', + ]); + }); + + test('path encoding rejects malformed Unicode', () => { + assert.throws(() => encodePathSegments('\uD800'), URIError); + }); + test('pull request keys preserve the host and number while normalizing case and trailing slashes', () => { assert.deepStrictEqual([ ' HTTPS://GITHUB.COM/Owner/Repo/PULL/42/// ', diff --git a/src/vs/platform/notification/common/notification.ts b/src/vs/platform/notification/common/notification.ts index aa66bdfa81ae49..a8361ed38598c5 100644 --- a/src/vs/platform/notification/common/notification.ts +++ b/src/vs/platform/notification/common/notification.ts @@ -239,6 +239,8 @@ export interface INotificationProgress { } export interface INotificationHandle { + /** Current visibility, when supported, including a toast shown before listeners attach. */ + readonly visible?: boolean; /** * Will be fired once the notification is closed. diff --git a/src/vs/platform/storage/common/storage.ts b/src/vs/platform/storage/common/storage.ts index 2e967d799f7789..9565d6919f9ffb 100644 --- a/src/vs/platform/storage/common/storage.ts +++ b/src/vs/platform/storage/common/storage.ts @@ -106,6 +106,12 @@ export interface IStorageService { get(key: string, scope: StorageScope, fallbackValue: string): string; get(key: string, scope: StorageScope, fallbackValue?: string): string | undefined; + /** Read authoritative shared state, bypassing the renderer cache. */ + readApplicationSharedValue(key: string): Promise; + + /** Atomically update local, machine-only shared state across windows. */ + compareAndSwapApplicationSharedValue(key: string, expectedValue: string | undefined, newValue: string): Promise<{ swapped: boolean; currentValue: string | undefined }>; + /** * Retrieve an element stored with the given key from storage. Use * the provided `defaultValue` if the element is `null` or `undefined`. @@ -356,6 +362,17 @@ export abstract class AbstractStorageService extends Disposable implements IStor return Event.filter(this._onDidChangeValue.event, e => e.scope === scope && (key === undefined || e.key === key), disposable); } + async readApplicationSharedValue(key: string): Promise { + return this.get(key, StorageScope.APPLICATION_SHARED); + } + + async compareAndSwapApplicationSharedValue(key: string, expectedValue: string | undefined, newValue: string): Promise<{ swapped: boolean; currentValue: string | undefined }> { + const currentValue = this.get(key, StorageScope.APPLICATION_SHARED); + if (currentValue !== expectedValue) { return { swapped: false, currentValue }; } + this.store(key, newValue, StorageScope.APPLICATION_SHARED, StorageTarget.MACHINE); + return { swapped: true, currentValue: newValue }; + } + private doFlushWhenIdle(): void { this.runFlushWhenIdle.value = runWhenGlobalIdle(() => { if (this.shouldFlushWhenIdle()) { @@ -532,7 +549,7 @@ export abstract class AbstractStorageService extends Disposable implements IStor return keys; } - private updateKeyTarget(key: string, scope: StorageScope, target: StorageTarget | undefined, external = false): void { + protected updateKeyTarget(key: string, scope: StorageScope, target: StorageTarget | undefined, external = false): void { // Add const keyTargets = this.getKeyTargets(scope); diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index b2c64d6e105fd3..218d5f23167bd6 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -46,6 +46,8 @@ At most one high-priority surface is visible in the main horizontal chain: norma The desktop presentation may place the Auxiliary Bar inside the Editor's grid node. Consumers must distinguish the actual Editor content area from the shared grid node when interpreting visibility or size. +The desktop Panel supports two profile-scoped alignments. The default justified alignment places it below the Sessions Part and side pane, preserving the original spanning layout. Center alignment places it below the Sessions Part only, allowing the side pane to use the full content height. Switching alignment reparents the existing grid views without changing panel height or side-pane width. The mobile presentation keeps the justified topology. + ## Sessions Part Each visible session has one Sessions-owned view. The view presents the active chat for that session and scopes commands, menus, and context keys to the represented session. diff --git a/src/vs/sessions/browser/desktopWorkbench.ts b/src/vs/sessions/browser/desktopWorkbench.ts index 04235de82af996..e20c2798feec0d 100644 --- a/src/vs/sessions/browser/desktopWorkbench.ts +++ b/src/vs/sessions/browser/desktopWorkbench.ts @@ -85,15 +85,12 @@ export class DesktopWorkbench extends Workbench { override setDockedAuxiliaryBarWidth(width: number): void { this._dockedAuxiliaryBarWidth = width; if (this.workbenchGrid && this.partVisibility.auxiliaryBar && !this.partVisibility.editor) { - this._syncingEditorVisibility = true; - try { + this._runWithEditorVisibilitySyncSuspended(() => { this.workbenchGrid.resizeView(this.editorPartView, { width: this._dockedAuxiliaryBarWidth, height: this.workbenchGrid.getViewSize(this.editorPartView).height, }); - } finally { - this._syncingEditorVisibility = false; - } + }); } this._layoutDockedAuxBar(); } @@ -252,6 +249,9 @@ export class DesktopWorkbench extends Workbench { } protected override _onGridDidChange(): void { + if (this._syncingEditorVisibility) { + return; + } const nodeWidth = this.workbenchGrid.getViewSize(this.editorPartView).width; this._syncEditorVisibility(nodeWidth); } @@ -274,8 +274,7 @@ export class DesktopWorkbench extends Workbench { return; } - this._syncingEditorVisibility = true; - try { + this._runWithEditorVisibilitySyncSuspended(() => { const effectiveAuxiliaryBarWidth = DockedAuxiliaryBarController.getEffectiveWidth(this._dockedAuxiliaryBarWidth, nodeWidth); const editorContentVisible = nodeWidth > effectiveAuxiliaryBarWidth + DesktopWorkbench._EDITOR_CONTENT_VISIBLE_THRESHOLD; @@ -291,21 +290,23 @@ export class DesktopWorkbench extends Workbench { this._savePartVisibility(); return; } - - } finally { - this._syncingEditorVisibility = false; - } + }); } - protected override _runWithEditorResizeSyncSuspended(fn: () => void): void { + private _runWithEditorVisibilitySyncSuspended(fn: () => void): void { + const wasSyncingEditorVisibility = this._syncingEditorVisibility; this._syncingEditorVisibility = true; try { fn(); } finally { - this._syncingEditorVisibility = false; + this._syncingEditorVisibility = wasSyncingEditorVisibility; } } + protected override _runWithEditorResizeSyncSuspended(fn: () => void): void { + this._runWithEditorVisibilitySyncSuspended(fn); + } + protected override _applyEditorVisibility(hidden: boolean): void { // Part sizes are workbench-global, so hiding the side pane must not discard the // user's chosen editor width. Capture the current editor content width before the @@ -402,15 +403,12 @@ export class DesktopWorkbench extends Workbench { this._editorNodeShouldBeVisible() ); if (!hidden && !this.partVisibility.editor) { - this._syncingEditorVisibility = true; - try { + this._runWithEditorVisibilitySyncSuspended(() => { this.workbenchGrid.resizeView(this.editorPartView, { width: this._dockedAuxiliaryBarWidth, height: this.workbenchGrid.getViewSize(this.editorPartView).height }); - } finally { - this._syncingEditorVisibility = false; - } + }); } } this._layoutDockedAuxBar(); diff --git a/src/vs/sessions/browser/layoutActions.ts b/src/vs/sessions/browser/layoutActions.ts index 5d4aa2fbf6df01..65ae710e12875d 100644 --- a/src/vs/sessions/browser/layoutActions.ts +++ b/src/vs/sessions/browser/layoutActions.ts @@ -17,7 +17,8 @@ import { registerIcon } from '../../platform/theme/common/iconRegistry.js'; import { CONTEXT_ACCESSIBILITY_MODE_ENABLED } from '../../platform/accessibility/common/accessibility.js'; import { TogglePanelAction } from '../../workbench/browser/parts/panel/panelActions.js'; import { LayoutDensityMenu } from '../../workbench/browser/actions/layoutDensityActions.js'; -import { IsAuxiliaryWindowContext, IsWindowAlwaysOnTopContext, PanelVisibleContext, SideBarVisibleContext } from '../../workbench/common/contextkeys.js'; +import { IsAuxiliaryWindowContext, IsWindowAlwaysOnTopContext, PanelAlignmentContext, PanelVisibleContext, SideBarVisibleContext } from '../../workbench/common/contextkeys.js'; +import { ViewContainerLocation, ViewContainerLocationToString } from '../../workbench/common/views.js'; import { IWorkbenchLayoutService, Parts } from '../../workbench/services/layout/browser/layoutService.js'; import { SessionsWelcomeVisibleContext, CustomViewVisibleContext, IsPhoneLayoutContext } from '../common/contextkeys.js'; @@ -85,6 +86,35 @@ class ToggleSidebarVisibilityAction extends Action2 { registerAction2(ToggleSidebarVisibilityAction); +class TogglePanelAlignmentAction extends Action2 { + + static readonly ID = 'workbench.action.agentSessions.togglePanelAlignment'; + + constructor() { + super({ + id: TogglePanelAlignmentAction.ID, + title: localize2('alignPanelWithChat', "Align Panel with Chat"), + toggled: PanelAlignmentContext.isEqualTo('center'), + menu: { + id: MenuId.ViewTitle, + group: '1_layout', + order: 1, + when: ContextKeyExpr.and( + ContextKeyExpr.equals('viewLocation', ViewContainerLocationToString(ViewContainerLocation.Panel)), + IsPhoneLayoutContext.negate(), + ), + }, + }); + } + + run(accessor: ServicesAccessor): void { + const layoutService = accessor.get(IWorkbenchLayoutService); + layoutService.setPanelAlignment(layoutService.getPanelAlignment() === 'center' ? 'justify' : 'center'); + } +} + +registerAction2(TogglePanelAlignmentAction); + MenuRegistry.appendMenuItem(Menus.TitleBarAccessibility, { command: { id: 'editor.action.toggleScreenReaderAccessibilityMode', diff --git a/src/vs/sessions/browser/media/workbench.css b/src/vs/sessions/browser/media/workbench.css index 2b4d97a649e165..c8dd5a7cbf987b 100644 --- a/src/vs/sessions/browser/media/workbench.css +++ b/src/vs/sessions/browser/media/workbench.css @@ -201,6 +201,14 @@ box-sizing: border-box; } +.agent-sessions-workbench.nosidebar:not(.phone-layout) .part.panel { + margin-left: var(--agents-floating-panel-gap); +} + +.agent-sessions-workbench.panel-alignment-center:not(.noeditorpane) .part.panel { + margin-right: var(--agents-floating-panel-gap); +} + /* Compact surfaces share a single separator without reserving space for card frames. */ .monaco-workbench.agent-sessions-workbench.modern-ui-compact .agents-part-card, .monaco-workbench.agent-sessions-workbench.modern-ui-compact .part:is(.panel, .auxiliarybar), @@ -256,11 +264,11 @@ /* * SplitView centers each sash on its logical boundary, but the card margins put - * the Sessions/Editor gap before that boundary and the Panel gap after it. - * Grow the hit area to at least the visual gap, then shift it toward that gap - * without moving the split itself. `max()` preserves larger configured sashes. + * the Sessions/side-pane gap before that boundary and the Panel gap after it. + * The layout marks the precise Sessions/side-pane sash because the centered + * topology nests Sessions below the panel and also has a sidebar sash. */ -.agent-sessions-workbench:not(.noeditorpane) .monaco-split-view2.horizontal:has(> .monaco-scrollable-element > .split-view-container > .split-view-view > .part.sessionspart) > .sash-container > .monaco-sash.vertical:first-child { +.agent-sessions-workbench:not(.noeditorpane) .monaco-sash.vertical.sessions-side-pane-divider { width: max(var(--vscode-sash-size), var(--agents-floating-panel-gap)); transform: translateX(calc((var(--vscode-sash-size) - max(var(--vscode-sash-size), var(--agents-floating-panel-gap)) - var(--agents-floating-panel-gap)) / 2)); } @@ -275,7 +283,7 @@ * dot is the pseudo-element and box shadows draw the outer dots; hide all three * while the full hover/drag highlight communicates the active resize target. */ -.agent-sessions-workbench:not(.noeditorpane) .monaco-split-view2.horizontal:has(> .monaco-scrollable-element > .split-view-container > .split-view-view > .part.sessionspart) > .sash-container > .monaco-sash.vertical:first-child:not(.disabled)::after, +.agent-sessions-workbench:not(.noeditorpane) .monaco-sash.vertical.sessions-side-pane-divider:not(.disabled)::after, .agent-sessions-workbench:not(.nopanel) .monaco-split-view2.vertical:has(> .monaco-scrollable-element > .split-view-container > .split-view-view > .part.panel) > .sash-container > .monaco-sash.horizontal:not(.disabled)::after { content: ''; position: absolute; @@ -291,7 +299,7 @@ transform: translate(-50%, -50%); } -.agent-sessions-workbench:not(.noeditorpane) .monaco-split-view2.horizontal:has(> .monaco-scrollable-element > .split-view-container > .split-view-view > .part.sessionspart) > .sash-container > .monaco-sash.vertical:first-child:not(.disabled)::after { +.agent-sessions-workbench:not(.noeditorpane) .monaco-sash.vertical.sessions-side-pane-divider:not(.disabled)::after { box-shadow: 0 calc(-1 * var(--agents-floating-panel-gap)) currentColor, 0 var(--agents-floating-panel-gap) currentColor; } @@ -299,19 +307,19 @@ box-shadow: calc(-1 * var(--agents-floating-panel-gap)) 0 currentColor, var(--agents-floating-panel-gap) 0 currentColor; } -.agent-sessions-workbench:not(.noeditorpane) .monaco-split-view2.horizontal:has(> .monaco-scrollable-element > .split-view-container > .split-view-view > .part.sessionspart) > .sash-container > .monaco-sash.vertical:first-child:is(.hover, .active)::after, +.agent-sessions-workbench:not(.noeditorpane) .monaco-sash.vertical.sessions-side-pane-divider:is(.hover, .active)::after, .agent-sessions-workbench:not(.nopanel) .monaco-split-view2.vertical:has(> .monaco-scrollable-element > .split-view-container > .split-view-view > .part.panel) > .sash-container > .monaco-sash.horizontal:is(.hover, .active)::after { opacity: 0; } -.monaco-enable-motion .agent-sessions-workbench:not(.noeditorpane) .monaco-split-view2.horizontal:has(> .monaco-scrollable-element > .split-view-container > .split-view-view > .part.sessionspart) > .sash-container > .monaco-sash.vertical:first-child:not(.disabled)::after, +.monaco-enable-motion .agent-sessions-workbench:not(.noeditorpane) .monaco-sash.vertical.sessions-side-pane-divider:not(.disabled)::after, .monaco-enable-motion .agent-sessions-workbench:not(.nopanel) .monaco-split-view2.vertical:has(> .monaco-scrollable-element > .split-view-container > .split-view-view > .part.panel) > .sash-container > .monaco-sash.horizontal:not(.disabled)::after { transition: opacity 0.1s ease-out; } @media (forced-colors: active) { - .agent-sessions-workbench:not(.noeditorpane) .monaco-split-view2.horizontal:has(> .monaco-scrollable-element > .split-view-container > .split-view-view > .part.sessionspart) > .sash-container > .monaco-sash.vertical:first-child:not(.disabled)::after, + .agent-sessions-workbench:not(.noeditorpane) .monaco-sash.vertical.sessions-side-pane-divider:not(.disabled)::after, .agent-sessions-workbench:not(.nopanel) .monaco-split-view2.vertical:has(> .monaco-scrollable-element > .split-view-container > .split-view-view > .part.panel) > .sash-container > .monaco-sash.horizontal:not(.disabled)::after { background: CanvasText; color: CanvasText; @@ -347,11 +355,17 @@ border-bottom-left-radius: var(--agents-window-corner-radius); } + &:not(.nopanel).nosidebar .part.panel { + border-bottom-left-radius: var(--agents-window-corner-radius); + } + &.nopanel.noeditorpane .agents-part-card, &.nopanel.noauxiliarybar .monaco-grid-view .part.editor:not(.modal-editor-part), &.nopanel.dock-detail-panel .monaco-grid-view .part.editor:not(.modal-editor-part), &.nopanel:not(.dock-detail-panel) .part.auxiliarybar, - &:not(.nopanel) .part.panel { + &:not(.nopanel):not(.panel-alignment-center) .part.panel, + &:not(.nopanel).panel-alignment-center.noeditorpane .part.panel, + &:not(.nopanel).panel-alignment-center:not(.noeditorpane).dock-detail-panel .monaco-grid-view .part.editor:not(.modal-editor-part) { border-bottom-right-radius: var(--agents-window-corner-radius); } diff --git a/src/vs/sessions/browser/parts/panelPart.ts b/src/vs/sessions/browser/parts/panelPart.ts index 86d2bca1853fe3..ee10244954488b 100644 --- a/src/vs/sessions/browser/parts/panelPart.ts +++ b/src/vs/sessions/browser/parts/panelPart.ts @@ -31,6 +31,8 @@ import { IPaneCompositeBarOptions } from '../../../workbench/browser/parts/paneC import { IHoverService } from '../../../platform/hover/browser/hover.js'; import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; import { Extensions } from '../../../workbench/browser/panecomposite.js'; +import { isPhoneLayout } from './mobile/mobileLayout.js'; +import { mainWindow } from '../../../base/browser/window.js'; /** * Panel part specifically for agent sessions workbench. @@ -119,6 +121,12 @@ export class PanelPart extends AbstractPaneCompositePart { this.updateCompositeBar(true); } })); + this._register(this.layoutService.onDidChangePanelAlignment(() => this.relayoutForInsets())); + this._register(this.layoutService.onDidChangePartVisibility(e => { + if (e.partId === Parts.SIDEBAR_PART || e.partId === Parts.EDITOR_PART || e.partId === Parts.AUXILIARYBAR_PART) { + this.relayoutForInsets(); + } + })); } override updateStyles(): void { @@ -179,8 +187,15 @@ export class PanelPart extends AbstractPaneCompositePart { const compact = this.layoutService.isModernUICompact(); const borderTotal = compact ? 0 : 2; const marginTop = compact ? 0 : PanelPart.MARGIN_TOP; + const marginLeft = !compact && !isPhoneLayout(this.layoutService) && !this.layoutService.isVisible(Parts.SIDEBAR_PART) + ? AGENTS_FLOATING_PANEL_GAP + : 0; + const editorPaneVisible = this.layoutService.isVisible(Parts.EDITOR_PART, mainWindow) || this.layoutService.isVisible(Parts.AUXILIARYBAR_PART); + const marginRight = !compact && this.layoutService.getPanelAlignment() === 'center' && editorPaneVisible + ? AGENTS_FLOATING_PANEL_GAP + : 0; super.layout( - width - borderTotal, + width - marginLeft - marginRight - borderTotal, height - marginTop - borderTotal, top, left ); @@ -189,6 +204,14 @@ export class PanelPart extends AbstractPaneCompositePart { Part.prototype.layout.call(this, width, height, top, left); } + private relayoutForInsets(): void { + const dimension = this.dimension; + const position = this.contentPosition; + if (dimension && position) { + this.layout(dimension.width, dimension.height, position.top, position.left); + } + } + protected override shouldShowCompositeBar(): boolean { return true; } diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index d42cee5dd72b7f..f3368c5f20d6db 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -9,7 +9,7 @@ import './media/workbench.css'; import './media/phoneLayout.css'; import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../base/common/lifecycle.js'; import { Emitter, Event, setGlobalLeakWarningThreshold } from '../../base/common/event.js'; -import { addDisposableGenericMouseDownListener, addDisposableListener, EventType, getActiveDocument, getActiveElement, getClientArea, getWindow, getWindowId, getWindows, IDimension, isAncestorUsingFlowTo, size, Dimension, runWhenWindowIdle } from '../../base/browser/dom.js'; +import { addDisposableGenericMouseDownListener, addDisposableListener, EventType, getActiveDocument, getActiveElement, getClientArea, getWindow, getWindowId, getWindows, IDimension, isAncestorUsingFlowTo, isHTMLElement, size, Dimension, runWhenWindowIdle } from '../../base/browser/dom.js'; import { DeferredPromise, RunOnceScheduler } from '../../base/common/async.js'; import { isFullscreen, onDidChangeFullscreen, isChrome, isFirefox, isSafari } from '../../base/browser/browser.js'; import { mark } from '../../base/common/performance.js'; @@ -18,7 +18,7 @@ import { isWindows, isLinux, isWeb, isNative, isMacintosh, isIOS } from '../../b import { Parts, Position, PanelAlignment, IWorkbenchLayoutService, SINGLE_WINDOW_PARTS, MULTI_WINDOW_PARTS, IPartVisibilityChangeEvent, positionToString, LayoutSettings, ModernUIDensity } from '../../workbench/services/layout/browser/layoutService.js'; import { ILayoutOffsetInfo } from '../../platform/layout/browser/layoutService.js'; import { Part } from '../../workbench/browser/part.js'; -import { Direction, ISerializableView, ISerializedGrid, ISerializedLeafNode, ISerializedNode, IViewSize, Orientation, SerializableGrid } from '../../base/browser/ui/grid/grid.js'; +import { Direction, ISerializableView, ISerializedGrid, ISerializedLeafNode, ISerializedNode, IViewSize, Orientation, SerializableGrid, Sizing } from '../../base/browser/ui/grid/grid.js'; import { IEditorGroupsService } from '../../workbench/services/editor/common/editorGroupsService.js'; import { IEditorService } from '../../workbench/services/editor/common/editorService.js'; import { IPaneCompositePartService } from '../../workbench/services/panecomposite/browser/panecomposite.js'; @@ -84,6 +84,7 @@ import { AGENTS_FLOATING_PANEL_GAP } from '../common/layoutConstants.js'; import { ITelemetryService } from '../../platform/telemetry/common/telemetry.js'; const PHONE_NOTIFICATION_ROW_HEIGHT = 44; +const SESSIONS_SIDE_PANE_DIVIDER_CLASS = 'sessions-side-pane-divider'; type SessionsWindowLayoutEvent = { layout: string; @@ -168,6 +169,8 @@ export const enum AgentWorkbenchLayout { Desktop = 'desktop', } +type AgentPanelAlignment = 'center' | 'justify'; + //#endregion export interface IAgentWorkbenchLayoutService extends IWorkbenchLayoutService, IDockedEditorLayout { @@ -415,6 +418,7 @@ export abstract class Workbench extends Disposable implements IAgentWorkbenchLay protected sessionsPartView!: ISerializableView; protected customViewGridPartView!: ISerializableView; + private _sessionsSidePaneDivider: HTMLElement | undefined; /** The editor part container; the auxiliary bar is docked inside it. */ protected _editorPartContainer: HTMLElement | undefined; @@ -439,6 +443,9 @@ export abstract class Workbench extends Disposable implements IAgentWorkbenchLay private readonly mobileTopBarDisposables = this._register(new DisposableStore()); private _editorMaximized = false; + private _panelAlignment: AgentPanelAlignment = 'justify'; + private _panelMaximizedEditorState: { width: number | undefined } | undefined; + private _maximumEditorDimensionsOverride: IDimension | undefined; private _customViewVisibleKey!: IContextKey; /** Guards the grid updates that show/hide the custom view from feeding back into the desired part visibility. */ private _applyingCustomViewGridVisibility = false; @@ -464,6 +471,7 @@ export abstract class Workbench extends Disposable implements IAgentWorkbenchLay private static readonly _PART_VISIBILITY_KEY = 'workbench.sessions.partVisibility'; private static readonly _PART_SIZES_KEY = 'workbench.sessions.partSizes'; + private static readonly _PANEL_ALIGNMENT_KEY = 'workbench.sessions.panelAlignment'; //#region Services @@ -797,6 +805,13 @@ export abstract class Workbench extends Disposable implements IAgentWorkbenchLay return {}; } + private _loadPanelAlignment(storageService: IStorageService): AgentPanelAlignment { + if (this.agentWorkbenchLayout === AgentWorkbenchLayout.Desktop && storageService.get(Workbench._PANEL_ALIGNMENT_KEY, StorageScope.PROFILE) === 'center') { + return 'center'; + } + return 'justify'; + } + /** * Overlays the persisted part visibility on top of the current * (layout-policy default) `partVisibility` state. Must run before the @@ -1226,6 +1241,7 @@ export abstract class Workbench extends Disposable implements IAgentWorkbenchLay this.customViewGridPartService = accessor.get(ICustomViewGridPartService); this.instantiationService = accessor.get(IInstantiationService); this.storageService = accessor.get(IStorageService); + this._panelAlignment = this._loadPanelAlignment(this.storageService); accessor.get(ITitleService); // Resolve the desktop layout mode once (reload to toggle). @@ -1572,9 +1588,12 @@ export abstract class Workbench extends Disposable implements IAgentWorkbenchLay this.mainContainer.setAttribute('role', 'application'); this.workbenchGrid = workbenchGrid; this.workbenchGrid.edgeSnapping = this.mainWindowFullscreen; + this._updateSessionsSidePaneDivider(); this._register(this.workbenchGrid.onDidChange(() => { + this._updateSessionsSidePaneDivider(); this._onGridDidChange(); })); + this._registerGridMaximizationListener(); // If the editor is restored visible, it already has an established // width, so a later reveal must not force an even split over it. @@ -1639,6 +1658,42 @@ export abstract class Workbench extends Disposable implements IAgentWorkbenchLay })); } + private _registerGridMaximizationListener(): void { + this._register(this.workbenchGrid.onDidChangeViewMaximized(() => { + this._fireDidChangePartVisibility(Parts.PANEL_PART, this.isVisible(Parts.PANEL_PART)); + })); + } + + private _updateSessionsSidePaneDivider(): void { + this._sessionsSidePaneDivider?.classList.remove(SESSIONS_SIDE_PANE_DIVIDER_CLASS); + this._sessionsSidePaneDivider = undefined; + + const sessionsElement = this.sessionsPartView.element; + const editorElement = this.editorPartView.element; + let splitView = sessionsElement.closest('.monaco-split-view2.horizontal'); + + while (splitView && this.workbenchGrid.element.contains(splitView)) { + const scrollableElement = Array.from(splitView.children).find(element => element.classList.contains('monaco-scrollable-element')); + const viewContainer = scrollableElement + ? Array.from(scrollableElement.children).find(element => element.classList.contains('split-view-container')) + : undefined; + const sashContainer = Array.from(splitView.children).find(element => element.classList.contains('sash-container')); + if (viewContainer && sashContainer) { + const views = Array.from(viewContainer.children).filter(isHTMLElement); + const sessionsIndex = views.findIndex(view => view.contains(sessionsElement)); + const editorIndex = views.findIndex(view => view.contains(editorElement)); + if (sessionsIndex >= 0 && editorIndex === sessionsIndex + 1) { + const sashes = Array.from(sashContainer.children).filter(isHTMLElement); + this._sessionsSidePaneDivider = sashes[sessionsIndex]; + this._sessionsSidePaneDivider?.classList.add(SESSIONS_SIDE_PANE_DIVIDER_CLASS); + return; + } + } + + splitView = splitView.parentElement?.closest('.monaco-split-view2.horizontal') ?? null; + } + } + createWorkbenchManagement(instantiationService: IInstantiationService): void { // Welcome — must be created early in layout so the widget can gate // other UI until sign-in / chat setup is complete. @@ -1652,8 +1707,8 @@ export abstract class Workbench extends Disposable implements IAgentWorkbenchLay * - Sidebar (left, spans full height from top to bottom) * - Right section (vertical): * - Titlebar (top of right section) - * - Top right (horizontal): Chat Bar | Editor | Auxiliary Bar - * - Panel (below chat, editor, and auxiliary bar) + * - Chat Bar | Editor | Auxiliary Bar + * - Panel below either the Chat Bar or the entire main region */ private createGridDescriptor(): ISerializedGrid { const { width, height } = this._mainContainerDimension; @@ -1769,6 +1824,39 @@ export abstract class Workbench extends Disposable implements IAgentWorkbenchLay size: topRightHeight }; + if (this.agentWorkbenchLayout === AgentWorkbenchLayout.Desktop && this.getPanelAlignment() === 'center') { + const chatColumn: ISerializedNode = { + type: 'branch', + data: [ + { ...sessionsNode, size: topRightHeight }, + panelNode, + ], + size: sessionsWidth, + visible: sessionsNode.visible === false && panelNode.visible === false ? false : undefined, + }; + + return { + root: { + type: 'branch', + size: width, + data: [ + titleBarNode, + { + type: 'branch', + data: [ + sideBarNode, + ...this._topRightSectionChildren(chatColumn, editorNode, auxiliaryBarNode, customViewGridNode), + ], + size: contentHeight, + } + ] + }, + orientation: Orientation.VERTICAL, + width, + height + }; + } + // Right section: Top Right | Panel (vertical) const rightSection: ISerializedNode = { type: 'branch', @@ -1999,6 +2087,7 @@ export abstract class Workbench extends Disposable implements IAgentWorkbenchLay LayoutClasses.STATUSBAR_HIDDEN, // agents window never has a status bar this.mainWindowFullscreen ? LayoutClasses.FULLSCREEN : undefined, this.layoutPolicy.viewportClass.get() === 'phone' ? LayoutClasses.PHONE_LAYOUT : undefined, + `panel-alignment-${this.getPanelAlignment()}`, this.isModernUICompact() ? LayoutClasses.MODERN_UI_COMPACT : undefined, ]); } @@ -2663,7 +2752,7 @@ export abstract class Workbench extends Disposable implements IAgentWorkbenchLay //#endregion - //#region Position Methods (Fixed - Not Configurable) + //#region Position Methods getSideBarPosition(): Position { return Position.LEFT; // Always left in this layout @@ -2678,11 +2767,88 @@ export abstract class Workbench extends Disposable implements IAgentWorkbenchLay } getPanelAlignment(): PanelAlignment { - return 'justify'; // Full width panel + return this._panelAlignment; } - setPanelAlignment(_alignment: PanelAlignment): void { - // No-op: Panel alignment is fixed in this layout + setPanelAlignment(alignment: PanelAlignment): void { + if ( + this.agentWorkbenchLayout !== AgentWorkbenchLayout.Desktop + || (alignment !== 'center' && alignment !== 'justify') + || alignment === this._panelAlignment + ) { + return; + } + + const exitMaximizedPanel = alignment === 'justify' && this.isPanelMaximized(); + const activeElement = getActiveElement(); + const focusedGridElement = isHTMLElement(activeElement) && this.workbenchGrid.element?.contains(activeElement) ? activeElement : undefined; + const focusedPart = [Parts.PANEL_PART, Parts.EDITOR_PART, Parts.AUXILIARYBAR_PART, Parts.SESSIONS_PART].find(part => this.hasFocus(part)) as SINGLE_WINDOW_PARTS | undefined; + const previousAlignment = this._panelAlignment; + const previousMaximumEditorDimensionsOverride = this._maximumEditorDimensionsOverride; + const maximizedEditorWidth = exitMaximizedPanel ? this._panelMaximizedEditorState?.width : undefined; + try { + this._runWithEditorResizeSyncSuspended(() => { + if (exitMaximizedPanel) { + this.workbenchGrid.exitMaximizedView(); + } + this._maximumEditorDimensionsOverride ??= this.getMaximumEditorDimensions(this.mainContainer); + if (alignment === 'center') { + const panelSize = this._viewMoveSizing(this.panelPartView, 'height'); + this.workbenchGrid.moveView(this.panelPartView, panelSize, this.sessionsPartView, Direction.Down); + } else { + const sessionsWidth = this.workbenchGrid.isViewVisible(this.sessionsPartView) + ? this.workbenchGrid.getViewSize(this.sessionsPartView).width + : undefined; + const editorSize = maximizedEditorWidth ?? this._viewMoveSizing(this.editorPartView, 'width'); + const customViewGridSize = this._viewMoveSizing(this.customViewGridPartView, 'width'); + this.workbenchGrid.moveView(this.editorPartView, editorSize, this.sessionsPartView, Direction.Right); + this.workbenchGrid.moveView(this.customViewGridPartView, customViewGridSize, this.editorPartView, Direction.Right); + if (typeof sessionsWidth === 'number') { + this.workbenchGrid.resizeView(this.sessionsPartView, { + width: sessionsWidth, + height: this.workbenchGrid.getViewSize(this.sessionsPartView).height, + }); + } + if (typeof editorSize === 'number') { + this.workbenchGrid.resizeView(this.editorPartView, { + width: editorSize, + height: this.workbenchGrid.getViewSize(this.editorPartView).height, + }); + } + } + this.mainContainer.classList.toggle(`panel-alignment-${previousAlignment}`, false); + this.mainContainer.classList.toggle(`panel-alignment-${alignment}`, true); + this._layoutGrid(); + }); + } catch (error) { + this.mainContainer.classList.toggle(`panel-alignment-${alignment}`, false); + this.mainContainer.classList.toggle(`panel-alignment-${previousAlignment}`, true); + throw error; + } finally { + this._maximumEditorDimensionsOverride = previousMaximumEditorDimensionsOverride; + if (exitMaximizedPanel) { + this._panelMaximizedEditorState = undefined; + } + } + + this._panelAlignment = alignment; + this.storageService.store(Workbench._PANEL_ALIGNMENT_KEY, alignment, StorageScope.PROFILE, StorageTarget.USER); + this._onDidChangePanelAlignment.fire(alignment); + + if (focusedGridElement?.isConnected) { + focusedGridElement.focus(); + } else if (focusedPart) { + this.focusPart(focusedPart); + } + } + + private _viewMoveSizing(view: ISerializableView, dimension: 'width' | 'height'): number | Sizing { + if (this.workbenchGrid.isViewVisible(view)) { + return this.workbenchGrid.getViewSize(view)[dimension]; + } + + const currentSize = this.workbenchGrid.getViewSize(view)[dimension]; + return Sizing.Invisible(this.workbenchGrid.getViewCachedVisibleSize(view) ?? currentSize); } //#endregion @@ -2750,6 +2916,10 @@ export abstract class Workbench extends Disposable implements IAgentWorkbenchLay } getMaximumEditorDimensions(_container: HTMLElement): IDimension { + if (this._maximumEditorDimensionsOverride) { + return this._maximumEditorDimensionsOverride; + } + // Return the available space for editor (excluding other parts) const sidebarWidth = this.partVisibility.sidebar ? this.workbenchGrid.getViewSize(this.sideBarPartView).width : 0; const auxiliaryBarWidth = this.partVisibility.auxiliaryBar @@ -2774,9 +2944,27 @@ export abstract class Workbench extends Disposable implements IAgentWorkbenchLay } if (this.isPanelMaximized()) { - this.workbenchGrid.exitMaximizedView(); + const editorWidth = this._panelMaximizedEditorState?.width; + this._runWithEditorResizeSyncSuspended(() => { + this.workbenchGrid.exitMaximizedView(); + if (typeof editorWidth === 'number') { + this.workbenchGrid.resizeView(this.editorPartView, { + width: editorWidth, + height: this.workbenchGrid.getViewSize(this.editorPartView).height + }); + } + }); + this._panelMaximizedEditorState = undefined; } else { - this.workbenchGrid.maximizeView(this.panelPartView, [this.titleBarPartView, this.sideBarPartView]); + const editorState = { + width: this.workbenchGrid.isViewVisible(this.editorPartView) + ? this.workbenchGrid.getViewSize(this.editorPartView).width + : undefined + }; + this._runWithEditorResizeSyncSuspended(() => { + this.workbenchGrid.maximizeView(this.panelPartView, [this.titleBarPartView, this.sideBarPartView]); + }); + this._panelMaximizedEditorState = editorState; } } diff --git a/src/vs/sessions/common/agentHostSessionsProvider.ts b/src/vs/sessions/common/agentHostSessionsProvider.ts index ab94bff7230d0c..dc862fbe86f917 100644 --- a/src/vs/sessions/common/agentHostSessionsProvider.ts +++ b/src/vs/sessions/common/agentHostSessionsProvider.ts @@ -15,6 +15,7 @@ import { ResolveSessionConfigResult, SessionConfigValueItem } from '../../platfo import { AgentCustomization, Customization, McpServerStatus, RootConfigState, type CustomizationEnablement, type McpServerState, type RootState, type TextRange } from '../../platform/agentHost/common/state/protocol/state.js'; import { type CustomizationDisabledReason } from '../../platform/agentHost/common/customizationEnablement.js'; import { type McpServerSource } from '../../platform/agentHost/common/meta/mcpCustomizationMeta.js'; +import type { IMcpServerConfiguration } from '../../platform/mcp/common/mcpPlatformTypes.js'; import { ISessionConfigProvider, ISessionsProvider } from '../services/sessions/common/sessionsProvider.js'; import { ISessionAgentRef } from '../services/sessions/common/session.js'; import type { AgentMergeSessionOverrides, AgentMergeSessionState } from '../../platform/agentHost/common/agentMerge.js'; @@ -101,6 +102,22 @@ export interface IAgentHostMcpServer { /** Optional Connector catalog name for presentation only. */ readonly displayName?: string; readonly source?: McpServerSource; + /** + * Plugin the host reports as the source of this server's configuration. Unlike + * {@link isPluginProvided}, the plugin may be one the client never published, + * such as a plugin bundled with the agent. + */ + readonly sourcePluginName?: string; + /** + * Definition held by the agent host's own MCP server configuration, for a server configured there + * (for example with MCP: Add Server > Add to Current Agent Session) rather than in a file. + */ + readonly hostConfiguration?: IMcpServerConfiguration; + /** + * VS Code setting the host declares as controlling whether it includes this server. Present only when + * the providing host says so; never inferred from the server's name. + */ + readonly controllingSettingId?: string; readonly enabled: boolean; readonly enablement?: readonly CustomizationEnablement[]; readonly isPluginProvided?: boolean; diff --git a/src/vs/sessions/contrib/chat/browser/modelPicker.ts b/src/vs/sessions/contrib/chat/browser/modelPicker.ts index 52f756a6c82efb..fb82a824260f55 100644 --- a/src/vs/sessions/contrib/chat/browser/modelPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/modelPicker.ts @@ -84,6 +84,10 @@ export class ModelPicker extends Disposable { }, getModels: () => [...this._selectionModel.state.get().models], getProvider: () => getAgentHostProviderForTelemetry(this._sessionContext.session.get()?.sessionType, this._chatSessionsService), + getSessionType: () => { + const state = this._selectionModel.state.get(); + return (state.currentModel ?? state.models[0])?.metadata.targetChatSessionType; + }, getPresentationOptions: () => ({ ...this._selectionModel.state.get().options, showModelIcon: true, diff --git a/src/vs/sessions/contrib/chat/browser/openSessionLinkOpener.contribution.ts b/src/vs/sessions/contrib/chat/browser/openSessionLinkOpener.contribution.ts index 095d4fe0c6b679..3e5934de9ae20e 100644 --- a/src/vs/sessions/contrib/chat/browser/openSessionLinkOpener.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/openSessionLinkOpener.contribution.ts @@ -10,7 +10,7 @@ import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { IAgentHostConnectionsService } from '../../../../platform/agentHost/common/agentHostConnectionsService.js'; -import { AGENT_HOST_CHAT_LINK_PATTERN, AGENT_HOST_SESSION_ONLY_LINK_PATTERN, AgentSessionLinkStatus, buildAgentSessionLinkPresentation, parseOpenSessionLinkChatId, parseOpenSessionLinkUri } from '../../../../platform/agentHost/common/openSessionLink.js'; +import { AGENT_HOST_CHAT_LINK_PATTERN, AGENT_HOST_SESSION_ONLY_LINK_PATTERN, AgentSessionLinkStatus, buildAgentSessionLinkPresentation, parseOpenSessionLinkChatId, parseOpenSessionLinkConnectionAuthority, parseOpenSessionLinkUri } from '../../../../platform/agentHost/common/openSessionLink.js'; import { ILinkPresentation, ILinkPresentationService, ILinkPresentationWatcher } from '../../../../platform/dataChannel/common/dataChannel.js'; import { ILabelService } from '../../../../platform/label/common/label.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; @@ -81,7 +81,7 @@ export class OpenSessionLinkOpenerContribution extends Disposable implements IWo private _findSessionForLink(resource: URI | string): ISession | undefined { const backendSession = parseOpenSessionLinkUri(resource); return backendSession - ? findSessionForOpenSessionLink(backendSession, this._sessionsManagementService, this._connectionsService) + ? findSessionForOpenSessionLink(backendSession, this._sessionsManagementService, this._connectionsService, parseOpenSessionLinkConnectionAuthority(resource)) : undefined; } @@ -116,7 +116,7 @@ class AgentSessionLinkPresentationWatcher extends Disposable implements ILinkPre reader => { sessionsChanged.read(reader); const session = backendSession - ? findSessionForOpenSessionLink(backendSession, sessionsManagementService, connectionsService) + ? findSessionForOpenSessionLink(backendSession, sessionsManagementService, connectionsService, parseOpenSessionLinkConnectionAuthority(resource)) : undefined; return session ? readSessionState(session, chatId, reader, kind) : undefined; }, @@ -158,12 +158,16 @@ export function findSessionForOpenSessionLink( backendSession: URI, sessionsManagementService: ISessionsManagementService, connectionsService: IAgentHostConnectionsService, + connectionAuthority?: string, ): ISession | undefined { return sessionsManagementService.getSessions().find(session => { + const identity = connectionsService.resolveSessionResourceIdentity(session.resource); + if (connectionAuthority && identity?.connectionAuthority !== connectionAuthority) { + return false; + } if (isEqual(session.resource, backendSession)) { return true; } - const identity = connectionsService.resolveSessionResourceIdentity(session.resource); return !!identity && isEqual(identity.backendSession, backendSession); }); } diff --git a/src/vs/sessions/contrib/chat/browser/sessionArchiveNudge.ts b/src/vs/sessions/contrib/chat/browser/sessionArchiveNudge.ts index 284581e695980e..0d113edb12044f 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionArchiveNudge.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionArchiveNudge.ts @@ -7,6 +7,7 @@ import { structuralEquals } from '../../../../base/common/equals.js'; import { Event } from '../../../../base/common/event.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { autorun, derived, derivedOpts, IObservable, IReader, observableSignalFromEvent, observableValue } from '../../../../base/common/observable.js'; +import { getComparisonKey } from '../../../../base/common/resources.js'; import { localize } from '../../../../nls.js'; import { getChatSessionArchiveActionWording } from '../../../../platform/chat/common/sessionArchiveActions.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; @@ -18,6 +19,8 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../platfo import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { logSettingExperimentTrigger } from '../../../../platform/telemetry/common/experimentTrigger.js'; import { IChatSessionArchiveNudgeOptions } from '../../../../workbench/contrib/chat/browser/widget/input/chatSessionArchiveNudge.js'; +import { IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; +import { IChatModel } from '../../../../workbench/contrib/chat/common/model/chatModel.js'; import { onboardingScenarioRegistry } from '../../../../workbench/contrib/onboarding/common/onboardingRegistry.js'; import { OnboardingOutcome } from '../../../../workbench/contrib/onboarding/common/onboardingScenario.js'; import { IOnboardingScenarioService, ONBOARDING_ENABLED_CONFIG } from '../../../../workbench/contrib/onboarding/common/onboardingScenarioService.js'; @@ -37,6 +40,7 @@ import { SessionsView, SessionsViewId } from '../../sessions/browser/views/sessi export const SESSION_ARCHIVE_NUDGE_SETTING = 'chat.agentSessions.archiveNudge.enabled'; const DISMISSED_STORAGE_KEY_PREFIX = 'sessions.archiveNudge.dismissed.'; +const IMPRESSION_STORAGE_KEY_PREFIX = 'sessions.archiveNudge.impression.'; const ARCHIVE_COUNT_STORAGE_KEY = 'sessions.archiveNudge.archiveCount'; const COMPACT_AFTER_ARCHIVE_COUNT = 3; @@ -46,6 +50,17 @@ interface ISessionArchiveNudgeState { readonly pullRequestCount: number; } +interface ISessionArchiveNudgeCandidate extends ISessionArchiveNudgeState { + readonly pullRequestKeys: readonly string[]; +} + +interface ISessionArchiveNudgeImpression { + readonly pullRequestKeys: readonly string[]; + readonly lastRequestIds: Record; + readonly shownAt: number; + readonly continued: boolean; +} + export interface ISessionArchiveNudgeService { readonly _serviceBrand: undefined; /** @@ -55,10 +70,11 @@ export interface ISessionArchiveNudgeService { */ readonly experimentTriggerPending: IObservable; isDismissed(session: ISession, reader: IReader | undefined): boolean; + hasContinued(session: ISession, pullRequestKeys: readonly string[], reader: IReader | undefined): boolean; shouldShowCompact(reader: IReader | undefined): boolean; /** Reports the enablement experiment trigger of a suggestion that would show if it were enabled and not dismissed. */ reportWouldShow(): void; - markShown(state: ISessionArchiveNudgeState): void; + markShown(state: ISessionArchiveNudgeCandidate): void; dismiss(state: ISessionArchiveNudgeState): void; showArchiveOnboarding(session: ISession): Promise; archive(state: ISessionArchiveNudgeState): Promise; @@ -87,6 +103,7 @@ export class SessionArchiveNudgeService extends Disposable implements ISessionAr private readonly _shown = new Set(); private readonly _dismissalChanged: IObservable; + private readonly _impressionChanged: IObservable; private readonly _archiveCountChanged: IObservable; private readonly _onboardingStore = this._register(new MutableDisposable()); private _onboardingInFlight: Promise | undefined; @@ -100,6 +117,7 @@ export class SessionArchiveNudgeService extends Disposable implements ISessionAr @IConfigurationService private readonly _configurationService: IConfigurationService, @IViewsService private readonly _viewsService: IViewsService, @IOnboardingScenarioService private readonly _onboardingService: IOnboardingScenarioService, + @IChatService private readonly _chatService: IChatService, ) { super(); @@ -108,6 +126,10 @@ export class SessionArchiveNudgeService extends Disposable implements ISessionAr event => event.key.startsWith(DISMISSED_STORAGE_KEY_PREFIX), )); this._archiveCountChanged = observableSignalFromEvent(this, this._storageService.onDidChangeValue(StorageScope.PROFILE, ARCHIVE_COUNT_STORAGE_KEY, this._store)); + this._impressionChanged = observableSignalFromEvent(this, Event.filter( + this._storageService.onDidChangeValue(StorageScope.PROFILE, undefined, this._store), + event => event.key.startsWith(IMPRESSION_STORAGE_KEY_PREFIX), + )); this._register(this._sessionsManagementService.onDidArchiveSession(session => this._clear(session))); this._register(this._sessionsManagementService.onDidUnarchiveSession(session => this._clear(session))); this._register(this._sessionsManagementService.onDidDeleteSession(session => this._clear(session))); @@ -116,9 +138,22 @@ export class SessionArchiveNudgeService extends Disposable implements ISessionAr this._register(autorun(reader => { sessionsChanged.read(reader); this._dismissalChanged.read(reader); + this._impressionChanged.read(reader); for (const session of this._sessionsManagementService.getSessions()) { - if (this.isDismissed(session, reader) && session.isArchived.read(reader)) { + const impression = this._getImpression(session, reader); + if ((impression || this.isDismissed(session, reader)) && session.isArchived.read(reader)) { this._clear(session); + } else if (impression && !impression.continued) { + const continued = this._getChatModels(session, reader).some(model => { + const request = model.lastRequestObs.read(reader); + const chatResource = getComparisonKey(model.sessionResource); + // Ignore older history revealed by request removal or lazy chat loading. + return !!request && request.timestamp >= impression.shownAt + && (!Object.hasOwn(impression.lastRequestIds, chatResource) || request.id !== impression.lastRequestIds[chatResource]); + }); + if (continued) { + this._storageService.store(`${IMPRESSION_STORAGE_KEY_PREFIX}${session.sessionId}`, { ...impression, continued: true }, StorageScope.PROFILE, StorageTarget.MACHINE); + } } } })); @@ -129,6 +164,11 @@ export class SessionArchiveNudgeService extends Disposable implements ISessionAr return this._storageService.getBoolean(`${DISMISSED_STORAGE_KEY_PREFIX}${session.sessionId}`, StorageScope.PROFILE, false); } + hasContinued(session: ISession, pullRequestKeys: readonly string[], reader: IReader | undefined): boolean { + const impression = this._getImpression(session, reader); + return !!impression?.continued && pullRequestKeys.every(key => impression.pullRequestKeys.includes(key)); + } + shouldShowCompact(reader: IReader | undefined): boolean { this._archiveCountChanged.read(reader); return this._storageService.getNumber(ARCHIVE_COUNT_STORAGE_KEY, StorageScope.PROFILE, 0) >= COMPACT_AFTER_ARCHIVE_COUNT; @@ -139,7 +179,19 @@ export class SessionArchiveNudgeService extends Disposable implements ISessionAr this._experimentTriggerPending.set(false, undefined); } - markShown(state: ISessionArchiveNudgeState): void { + markShown(state: ISessionArchiveNudgeCandidate): void { + const impression = this._getImpression(state.session, undefined); + if (!impression || state.pullRequestKeys.some(key => !impression.pullRequestKeys.includes(key))) { + const lastRequestIds = Object.fromEntries(this._getChatModels(state.session, undefined).map(model => [ + getComparisonKey(model.sessionResource), model.lastRequestObs.get()?.id, + ])); + this._storageService.store(`${IMPRESSION_STORAGE_KEY_PREFIX}${state.session.sessionId}`, { + pullRequestKeys: [...new Set([...(impression?.pullRequestKeys ?? []), ...state.pullRequestKeys])], + lastRequestIds, + shownAt: Date.now(), + continued: false, + } satisfies ISessionArchiveNudgeImpression, StorageScope.PROFILE, StorageTarget.MACHINE); + } if (!this._shown.has(state.session.sessionId)) { this._shown.add(state.session.sessionId); this._log(state, 'shown'); @@ -203,9 +255,20 @@ export class SessionArchiveNudgeService extends Disposable implements ISessionAr private _clear(session: ISession): void { this._shown.delete(session.sessionId); + this._storageService.remove(`${IMPRESSION_STORAGE_KEY_PREFIX}${session.sessionId}`, StorageScope.PROFILE); this._storageService.remove(`${DISMISSED_STORAGE_KEY_PREFIX}${session.sessionId}`, StorageScope.PROFILE); } + private _getImpression(session: ISession, reader: IReader | undefined): ISessionArchiveNudgeImpression | undefined { + this._impressionChanged.read(reader); + return this._storageService.getObject(`${IMPRESSION_STORAGE_KEY_PREFIX}${session.sessionId}`, StorageScope.PROFILE); + } + + private _getChatModels(session: ISession, reader: IReader | undefined): IChatModel[] { + const resources = new Set([session.mainChat.read(reader).resource, ...session.chats.read(reader).map(chat => chat.resource)].map(resource => getComparisonKey(resource))); + return [...this._chatService.chatModels.read(reader)].filter(model => resources.has(getComparisonKey(model.sessionResource))); + } + private _log(state: ISessionArchiveNudgeState, action: SessionArchiveNudgeEvent['action']): void { this._telemetryService.publicLog2('agents/sessionArchiveNudge', { agentSessionId: hashSessionIdForTelemetry(state.session.sessionId), @@ -216,7 +279,7 @@ export class SessionArchiveNudgeService extends Disposable implements ISessionAr } } -/** Keeps dismissal cleanup running even when no chat view is open. */ +/** Keeps suggestion history and lifecycle cleanup running even when no chat view is open. */ export class SessionArchiveNudgeContribution { static readonly ID = 'workbench.contrib.sessionArchiveNudge'; @@ -278,7 +341,7 @@ function getSessionPullRequests(session: ISession, reader: IReader): readonly { export class SessionArchiveNudge extends Disposable { readonly options: IObservable; - private readonly _state: IObservable; + private readonly _state: IObservable; constructor( session: IObservable, @@ -307,7 +370,8 @@ export class SessionArchiveNudge extends Disposable { const pullRequests = derivedOpts>({ owner: this, equalsFn: structuralEquals }, reader => { const current = eligibleSession.read(reader); // The shared model must not resolve github.com pull requests against an enterprise host. - return current && !gitHubService.enterpriseHost ? getSessionPullRequests(current, reader) : undefined; + const refs = current && !gitHubService.enterpriseHost ? getSessionPullRequests(current, reader) : undefined; + return current && refs && !this._nudgeService.hasContinued(current, refs.map(ref => getPullRequestKey(ref.owner, ref.repo, ref.number)), reader) ? refs : undefined; }); const models = derived(this, reader => { return pullRequests.read(reader)?.map(pullRequest => { @@ -319,13 +383,17 @@ export class SessionArchiveNudge extends Disposable { }); this._state = derivedOpts({ owner: this, - equalsFn: (a: ISessionArchiveNudgeState | undefined, b: ISessionArchiveNudgeState | undefined) => - a?.session === b?.session && a?.hasWorktree === b?.hasWorktree && a?.pullRequestCount === b?.pullRequestCount, + equalsFn: (a: ISessionArchiveNudgeCandidate | undefined, b: ISessionArchiveNudgeCandidate | undefined) => + a?.session === b?.session && a?.hasWorktree === b?.hasWorktree && structuralEquals(a?.pullRequestKeys, b?.pullRequestKeys), }, reader => { const current = eligibleSession.read(reader); if (!current) { return undefined; } + const refs = pullRequests.read(reader); + if (!refs?.length) { + return undefined; + } const pullRequestModels = models.read(reader); const pullRequestCount = pullRequestModels?.length; if (!pullRequestCount || !pullRequestModels?.every(model => model.pullRequest.read(reader)?.state === GitHubPullRequestState.Merged)) { @@ -336,6 +404,7 @@ export class SessionArchiveNudge extends Disposable { session: current, hasWorktree: !!workspace && !workspace.isVirtualWorkspace && (!!current.worktreePending?.read(reader) || workspace.folders.some(folder => !!folder.gitRepository?.workTreeUri)), pullRequestCount, + pullRequestKeys: refs.map(ref => getPullRequestKey(ref.owner, ref.repo, ref.number)), }; }); this._register(autorun(reader => { diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index b8b19ef2057298..ef665e061a310c 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -130,6 +130,7 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(archiveActionWording === ChatSessionArchiveActionWording.MarkAsDone ? localize('sessionsChat.doneNudge', "When all GitHub pull requests associated with the session have merged and no chat is working or waiting for input, a mark as done suggestion may appear above the chat input. Use Tab or Shift+Tab to reach Mark as Done, Configure Automatic Cleanup, or Dismiss Mark as Done Suggestion, then Enter or Space to activate it. Configure Automatic Cleanup opens the settings for automatically archiving inactive merged sessions and permanently deleting automatically archived merged sessions. The explanation under What Does \"Mark as Done\" Do? is collapsed by default. Use Tab to reach it and Enter or Space to expand or collapse it. Dismiss Mark as Done Suggestion, or Escape while the suggestion is focused, hides the suggestion for this session, including after a reload, until it is marked as done or deleted, and returns focus to the chat input. Marking the session as done hides it from the sessions list so you can focus on your remaining tasks. The session is not deleted. Ask your agent to find it, or look in the \"Done\" section of the sessions list. You can restore it anytime. The worktree created for the session, if any, will be deleted. You can recreate it by restoring the session.") : localize('sessionsChat.archiveNudge', "When all GitHub pull requests associated with the session have merged and no chat is working or waiting for input, an archive suggestion may appear above the chat input. Use Tab or Shift+Tab to reach Archive, Configure Automatic Cleanup, or Dismiss Archive Suggestion, then Enter or Space to activate it. Configure Automatic Cleanup opens the settings for automatically archiving inactive merged sessions and permanently deleting automatically archived merged sessions. The explanation under What Does \"Archive\" Do? is collapsed by default. Use Tab to reach it and Enter or Space to expand or collapse it. Dismiss Archive Suggestion, or Escape while the suggestion is focused, hides the suggestion for this session, including after a reload, until it is archived or deleted, and returns focus to the chat input. Archiving the session hides it from the sessions list so you can focus on your remaining tasks. The session is not deleted. Ask your agent to find it, or look in the \"Archived\" section of the sessions list. You can unarchive it anytime. The worktree created for the session, if any, will be deleted. You can recreate it by unarchiving the session.")); + content.push(localize('sessionsChat.archiveNudgeContinuation', "Sending a new message after the suggestion appears hides it for those pull requests, including after a reload. It can appear again when a new pull request is added to the session and all its pull requests have merged.")); } content.push(localize('sessionsChat.feedbackAttachment', "When a feedback comments attachment appears above the input, focus it and press Enter or Space. A single comment opens directly. Multiple comments open a tree grouped by file; use the arrow keys to navigate, Enter to reveal a comment, and Escape to close the tree.")); content.push(localize('sessionsChat.inputBackground', "Press Alt+Enter to start the session in the background without navigating into it. The started session appears in the Chat Sessions view.")); diff --git a/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts index 11439dfbed8056..502ad12c00404e 100644 --- a/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts @@ -12,7 +12,7 @@ import { localize } from '../../../../nls.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { IAgentHostByokLmHandler } from '../../../../platform/agentHost/common/agentHostByokLm.js'; import { IAgentHostConnectionsService } from '../../../../platform/agentHost/common/agentHostConnectionsService.js'; -import { buildExternalOpenSessionLinkUri, parseOpenSessionLinkChatId, parseOpenSessionLinkTurnId, parseOpenSessionLinkUri } from '../../../../platform/agentHost/common/openSessionLink.js'; +import { buildExternalOpenSessionLinkUri, parseOpenSessionLinkChatId, parseOpenSessionLinkConnectionAuthority, parseOpenSessionLinkTurnId, parseOpenSessionLinkUri } from '../../../../platform/agentHost/common/openSessionLink.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { AgentHostByokLmHandler } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostByokLmHandler.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; @@ -219,7 +219,8 @@ export class SelectAgentsFolderContribution extends Disposable implements IWorkb } private async resolveAndOpenSessionLink(sessionLink: URI, backendSession: URI, token: CancellationToken): Promise { - const session = await this.waitForSessionLinkAvailable(backendSession, token); + const authority = parseOpenSessionLinkConnectionAuthority(sessionLink); + const session = await this.waitForSessionLinkAvailable(backendSession, token, undefined, authority); if (token.isCancellationRequested) { return; } @@ -230,6 +231,7 @@ export class SelectAgentsFolderContribution extends Disposable implements IWorkb backendSession, parseOpenSessionLinkChatId(sessionLink), parseOpenSessionLinkTurnId(sessionLink), + authority, ); this.notificationService.error(localize('agentsHandoff.sessionNotFound', "The linked session could not be found: {0}", externalLink)); return; @@ -254,11 +256,11 @@ export class SelectAgentsFolderContribution extends Disposable implements IWorkb await this.sessionsService.openChat(session, chatResource, { source: 'link' }); } - private waitForSessionLinkAvailable(backendSession: URI, token: CancellationToken, timeoutMs = 15_000): Promise> { + private waitForSessionLinkAvailable(backendSession: URI, token: CancellationToken, timeoutMs = 15_000, connectionAuthority?: string): Promise> { if (token.isCancellationRequested) { return Promise.resolve(undefined); } - const findSession = () => findSessionForOpenSessionLink(backendSession, this.sessionsManagementService, this.agentHostConnectionsService); + const findSession = () => findSessionForOpenSessionLink(backendSession, this.sessionsManagementService, this.agentHostConnectionsService, connectionAuthority); const existing = findSession(); if (existing) { return Promise.resolve(existing); diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatInput.fixture.ts b/src/vs/sessions/contrib/chat/test/browser/newChatInput.fixture.ts index 86ec14dac1fea0..17418a134aa8f9 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatInput.fixture.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatInput.fixture.ts @@ -268,16 +268,20 @@ export default defineThemedFixtureGroup({ path: 'sessions/chat/newInput/' }, { // Where the pet lands, for each notice that can dock above the input (#332570). WithPet: defineComponentFixture({ + deferPaint: true, render: context => renderNewChatInput(context, { pet: true }), }), WithPetAndNotification: defineComponentFixture({ + deferPaint: true, render: context => renderNewChatInput(context, { notification: petPlatformNotification, pet: true }), }), WithPetAndGettingStartedTip: defineComponentFixture({ + deferPaint: true, render: context => renderNewChatInput(context, { gettingStartedTip: true, pet: true }), }), // The sub-session tip, docked from the composer's host slot. WithPetAndSubSessionTip: defineComponentFixture({ + deferPaint: true, render: context => renderNewChatInput(context, { subSessionTip: true, pet: true }), }), }); \ No newline at end of file diff --git a/src/vs/sessions/contrib/chat/test/browser/openSessionLinkOpener.test.ts b/src/vs/sessions/contrib/chat/test/browser/openSessionLinkOpener.test.ts index 192a1ed6d8abc8..225ca3c25550af 100644 --- a/src/vs/sessions/contrib/chat/test/browser/openSessionLinkOpener.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/openSessionLinkOpener.test.ts @@ -12,7 +12,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; -import { buildOpenSessionLinkUri } from '../../../../../platform/agentHost/common/openSessionLink.js'; +import { buildOpenSessionLinkUri, parseOpenSessionLinkConnectionAuthority } from '../../../../../platform/agentHost/common/openSessionLink.js'; import { ILinkPresentationProvider, ILinkPresentationService } from '../../../../../platform/dataChannel/common/dataChannel.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { IOpener, IOpenerService } from '../../../../../platform/opener/common/opener.js'; @@ -28,6 +28,24 @@ import { findSessionForOpenSessionLink, ISessionLinkChatState, ISessionLinkState suite('OpenSessionLinkOpenerContribution', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); + test('scoped links select the owning host when backend IDs collide', () => { + const backend = URI.parse('ahp-session:/shared'); + const local = upcastPartial({ resource: URI.parse('agent-host-codex:/shared') }); + const remote = upcastPartial({ resource: URI.parse('remote-other-host-claude:/shared') }); + const sessions = new class extends mock() { + override getSessions(): ISession[] { return [local, remote]; } + }(); + const connections = new class extends mock() { + override resolveSessionResourceIdentity(resource: URI) { + return { backendSession: backend, backendSessionIsAdvertised: true as const, connectionAuthority: resource.scheme === local.resource.scheme ? 'local' : 'other-host' }; + } + }(); + assert.deepStrictEqual(['other-host', 'missing-host', 'local'].map(authority => findSessionForOpenSessionLink(backend, sessions, connections, authority)?.resource.toString()), [remote.resource.toString(), undefined, local.resource.toString()]); + sessions.getSessions = () => [remote]; + const ambientLink = buildOpenSessionLinkUri(backend, undefined, undefined, 'local'); + assert.strictEqual(findSessionForOpenSessionLink(backend, sessions, connections, parseOpenSessionLinkConnectionAuthority(ambientLink)), undefined); + }); + const sessionsProvidersService = new class extends mock() { override getProvider(): T | undefined { return upcastPartial({ label: 'Local Agent Host', sessionTypes: [] }) as T; diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionArchiveNudge.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionArchiveNudge.test.ts index 706fec5be3ecfd..bfdeea70af5efb 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionArchiveNudge.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionArchiveNudge.test.ts @@ -29,6 +29,8 @@ import { NullWorkbenchAssignmentService } from '../../../../../workbench/service import { TestHostService, TestLayoutService } from '../../../../../workbench/test/browser/workbenchTestServices.js'; import { TestChatEntitlementService, TestLifecycleService, TestStorageService } from '../../../../../workbench/test/common/workbenchTestServices.js'; import { IViewsService } from '../../../../../workbench/services/views/common/viewsService.js'; +import { IChatService } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; +import { IChatModel, IChatRequestModel } from '../../../../../workbench/contrib/chat/common/model/chatModel.js'; import { OnboardingScenarioService } from '../../../../../workbench/contrib/onboarding/browser/onboardingService.js'; import { ISpotlightPayload, SPOTLIGHT_PRESENTATION_KIND } from '../../../../../workbench/contrib/onboarding/browser/spotlight/spotlightTypes.js'; import { SpotlightOverlay } from '../../../../../workbench/contrib/onboarding/browser/spotlight/spotlightOverlay.js'; @@ -206,6 +208,26 @@ suite('SessionArchiveNudge', () => { return viewAvailable ? view : null; }); const viewsService = instantiationService.get(IViewsService); + const chatModels = observableValue('chatModels', []); + const chatRequests = new Map>>(); + function setChatRequest(session: ISession, requestId: string | undefined, resource = session.mainChat.get().resource, timestamp = Date.now()) { + const key = resource.toString(); + let lastRequest = chatRequests.get(key); + const request = requestId === undefined ? undefined : upcastPartial({ id: requestId, timestamp }); + if (!lastRequest) { + lastRequest = observableValue('lastRequest', request); + chatRequests.set(key, lastRequest); + chatModels.set([...chatModels.get(), upcastPartial({ sessionResource: resource, lastRequestObs: lastRequest })], undefined); + } else { + lastRequest.set(request, undefined); + } + } + for (const session of sessions) { + setChatRequest(session, 'turn-1', undefined, 1); + } + const chatService = new class extends mock() { + override readonly chatModels = chatModels; + }(); store.add(onboardingPresentationRegistry.register({ kind: SPOTLIGHT_PRESENTATION_KIND, async run(scenario, runContext) { @@ -236,7 +258,7 @@ suite('SessionArchiveNudge', () => { new NullWorkbenchAssignmentService(), NullTelemetryService, )); - let service = store.add(new SessionArchiveNudgeService(storage, management, telemetry, configuration, viewsService, onboardingService)); + let service = store.add(new SessionArchiveNudgeService(storage, management, telemetry, configuration, viewsService, onboardingService, chatService)); const current = observableValue('current', sessions[0]); function createNudge() { const nudge = store.add(new SessionArchiveNudge(current, configuration, entitlement, github, service, commandService)); @@ -249,6 +271,7 @@ suite('SessionArchiveNudge', () => { get service() { return service; }, get counts() { return { references, polling, refreshes }; }, createNudge, + setChatRequest, onboarding: { service: onboardingService, events: onboardingEvents, @@ -260,7 +283,7 @@ suite('SessionArchiveNudge', () => { }, reloadService() { service.dispose(); - service = store.add(new SessionArchiveNudgeService(storage, management, telemetry, configuration, viewsService, onboardingService)); + service = store.add(new SessionArchiveNudgeService(storage, management, telemetry, configuration, viewsService, onboardingService, chatService)); }, setArchiveError(error: Error) { archiveError = error; }, setArchiveNoop() { archiveNoop = true; }, @@ -572,6 +595,221 @@ suite('SessionArchiveNudge', () => { }); }); + test('suppresses an exposed PR set after sending a new turn, not before exposure', () => { + const session = createSession(); + const context = setup([session]); + context.setPullRequest(1, GitHubPullRequestState.Merged); + const nudge = context.createNudge(); + const states = [!!nudge.options.get()]; + context.setChatRequest(session, 'turn-2'); + states.push(!!nudge.options.get()); + nudge.markShown(); + nudge.markShown(); + context.setChatRequest(session, 'turn-3'); + states.push(!!nudge.options.get()); + session.status.set(SessionStatus.InProgress, undefined); + session.status.set(SessionStatus.Completed, undefined); + states.push(!!nudge.options.get()); + nudge.markShown(); + context.setChatRequest(session, 'turn-4'); + states.push(!!nudge.options.get()); + + assert.deepStrictEqual({ states, live: context.counts.references, polling: context.counts.polling, impressions: context.events.length }, { + states: [true, true, false, false, false], live: 0, polling: 0, impressions: 1, + }); + }); + + test('removing the latest request does not suppress the nudge', () => runWithFakedTimers({ startTime: 100 }, async () => { + const session = createSession(); + const context = setup([session]); + context.setChatRequest(session, 'turn-2', undefined, 80); + context.setPullRequest(1, GitHubPullRequestState.Merged); + const nudge = context.createNudge(); + nudge.markShown(); + const states = [!!nudge.options.get()]; + context.setChatRequest(session, 'turn-1', undefined, 40); + states.push(!!nudge.options.get()); + context.setChatRequest(session, undefined); + states.push(!!nudge.options.get()); + nudge.dispose(); + context.reloadService(); + const reloaded = context.createNudge(); + states.push(!!reloaded.options.get()); + context.setChatRequest(session, 'turn-3', undefined, 101); + states.push(!!reloaded.options.get()); + + assert.deepStrictEqual(states, [true, true, true, true, false]); + })); + + test('remembers the shown turn across hidden views and reloads, and observes new turns without a view', () => { + const session = createSession(); + const context = setup([session]); + context.setPullRequest(1, GitHubPullRequestState.Merged); + const nudge = context.createNudge(); + nudge.markShown(); + context.current.set(undefined, undefined); + nudge.dispose(); + context.reloadService(); + context.current.set(session, undefined); + const reloaded = context.createNudge(); + const states = [!!reloaded.options.get()]; + reloaded.markShown(); + context.current.set(undefined, undefined); + reloaded.dispose(); + context.setChatRequest(session, 'turn-2'); + context.reloadService(); + context.current.set(session, undefined); + const continued = context.createNudge(); + states.push(!!continued.options.get()); + context.setChatRequest(session, 'turn-1', undefined, 1); + states.push(!!continued.options.get()); + + assert.deepStrictEqual(states, [true, false, false]); + }); + + test('rearms only for a new PR, waits for its merge, and suppresses it again after the next turn', () => { + const session = createSession(); + const context = setup([session]); + context.setPullRequest(1, GitHubPullRequestState.Merged); + const nudge = context.createNudge(); + nudge.markShown(); + context.setChatRequest(session, 'turn-2'); + const states: (number | undefined)[] = [nudge.options.get()?.pullRequestCount]; + session.artifacts.set([artifact(1, { label: 'Renamed', link: URI.parse('https://github.com/OWNER/REPO/pull/1/') }), artifact(2, { isArtifact: false })], undefined); + states.push(nudge.options.get()?.pullRequestCount); + context.setPullRequest(2, GitHubPullRequestState.Open); + session.artifacts.set([artifact(2), artifact(1)], undefined); + states.push(nudge.options.get()?.pullRequestCount); + context.setPullRequest(2, GitHubPullRequestState.Merged); + states.push(nudge.options.get()?.pullRequestCount); + nudge.markShown(); + session.artifacts.set([artifact(1), artifact(2)], undefined); + nudge.markShown(); + states.push(nudge.options.get()?.pullRequestCount); + context.setChatRequest(session, 'turn-3'); + states.push(nudge.options.get()?.pullRequestCount); + session.artifacts.set([artifact(1)], undefined); + states.push(nudge.options.get()?.pullRequestCount); + session.artifacts.set([artifact(1), artifact(2)], undefined); + states.push(nudge.options.get()?.pullRequestCount); + context.setPullRequest(3, GitHubPullRequestState.Merged); + session.artifacts.set([artifact(1), artifact(3)], undefined); + states.push(nudge.options.get()?.pullRequestCount); + nudge.markShown(); + context.setChatRequest(session, 'turn-4'); + states.push(nudge.options.get()?.pullRequestCount); + + assert.deepStrictEqual(states, [undefined, undefined, undefined, 2, 2, undefined, undefined, undefined, 2, undefined]); + }); + + test('detects a newer restored turn even if the service did not observe it being sent', () => { + const session = createSession(); + const context = setup([session]); + context.setPullRequest(1, GitHubPullRequestState.Merged); + const nudge = context.createNudge(); + nudge.markShown(); + nudge.dispose(); + context.service.dispose(); + context.setChatRequest(session, 'turn-2'); + context.reloadService(); + const reloaded = context.createNudge(); + + assert.strictEqual(reloaded.options.get(), undefined); + }); + + test('rejects a stale archive action after the user sends a new turn', async () => { + const session = createSession(); + const context = setup([session]); + context.setPullRequest(1, GitHubPullRequestState.Merged); + const nudge = context.createNudge(); + const action = nudge.options.get()!.onArchive; + nudge.markShown(); + context.setChatRequest(session, 'turn-2'); + + await assert.rejects(action(), /suggestion is no longer available/); + assert.deepStrictEqual(context.archiveTargets, []); + }); + + test('remembers same-count PR replacements even when the suggestion stays visible', () => { + const session = createSession(); + const context = setup([session]); + context.setPullRequest(1, GitHubPullRequestState.Merged); + context.setPullRequest(2, GitHubPullRequestState.Merged); + const nudge = context.createNudge(); + store.add(autorun(reader => { + if (nudge.options.read(reader)) { + nudge.markShown(); + } + })); + session.artifacts.set([artifact(2)], undefined); + context.setChatRequest(session, 'turn-2'); + session.artifacts.set([artifact(1)], undefined); + + assert.strictEqual(nudge.options.get(), undefined); + }); + + test('a new turn in a peer chat suppresses the session but loading older history does not', () => { + const session = createSession(); + const peerResource = URI.from({ scheme: 'test-chat', path: '/peer' }); + session.chats.set([upcastPartial({ + resource: peerResource, + status: observableValue('peerStatus', SessionStatus.Completed), + })], undefined); + const context = setup([session]); + context.setPullRequest(1, GitHubPullRequestState.Merged); + const nudge = context.createNudge(); + nudge.markShown(); + context.setChatRequest(session, 'old-peer-turn', peerResource, 1); + const states = [!!nudge.options.get()]; + context.setChatRequest(session, 'new-peer-turn', peerResource); + states.push(!!nudge.options.get()); + + assert.deepStrictEqual(states, [true, false]); + }); + + test('continuation is isolated per session and does not override explicit dismissal', () => { + const first = createSession('first'); + const second = createSession('second'); + const context = setup([first, second]); + context.setPullRequest(1, GitHubPullRequestState.Merged); + const nudge = context.createNudge(); + nudge.markShown(); + context.setChatRequest(first, 'turn-2'); + const states = [!!nudge.options.get()]; + context.current.set(second, undefined); + states.push(!!nudge.options.get()); + nudge.markShown(); + nudge.options.get()!.onDismiss(); + context.setChatRequest(second, 'turn-2'); + context.setPullRequest(2, GitHubPullRequestState.Merged); + second.artifacts.set([artifact(1), artifact(2)], undefined); + states.push(!!nudge.options.get()); + + assert.deepStrictEqual(states, [false, true, false]); + }); + + for (const lifecycle of ['archive', 'unarchive', 'delete', 'observedArchive'] as const) { + test(`clears the shown turn and PR set on ${lifecycle}`, () => { + const session = createSession(); + const context = setup([session]); + context.setPullRequest(1, GitHubPullRequestState.Merged); + const nudge = context.createNudge(); + nudge.markShown(); + context.setChatRequest(session, 'turn-2'); + if (lifecycle === 'observedArchive') { + session.isArchived.set(true, undefined); + session.isArchived.set(false, undefined); + } else { + ({ archive: context.archived, unarchive: context.unarchived, delete: context.deleted })[lifecycle].fire(session); + } + + assert.deepStrictEqual({ + visible: !!nudge.options.get(), + keys: context.storage.keys(StorageScope.PROFILE, StorageTarget.MACHINE), + }, { visible: true, keys: [] }); + }); + } + test('describes actual worktrees without treating missing or virtual workspaces as worktrees', () => { const session = createSession(); const context = setup([session]); diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionsChatAccessibilityHelp.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionsChatAccessibilityHelp.test.ts index 32f4ebbfef1971..01c67173e017db 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionsChatAccessibilityHelp.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionsChatAccessibilityHelp.test.ts @@ -555,6 +555,7 @@ suite('SessionsChatAccessibilityHelp', () => { focus: nudgeHelp?.includes('returns focus to the chat input'), close: nudgeHelp?.includes('Close'), onboarding: content.includes('The action waits until you activate the highlighted action, activate Understood, or press Escape to end the spotlight.'), + continuation: content.includes('Sending a new message after the suggestion appears hides it for those pull requests, including after a reload. It can appear again when a new pull request is added to the session and all its pull requests have merged.'), sessionListHelp, }, { controls: true, @@ -563,6 +564,7 @@ suite('SessionsChatAccessibilityHelp', () => { focus: true, close: false, onboarding: true, + continuation: true, sessionListHelp: expectedSessionListHelp, }); }); diff --git a/src/vs/sessions/contrib/onboardingTours/browser/newSessionViewV2TourContribution.ts b/src/vs/sessions/contrib/onboardingTours/browser/newSessionViewV2TourContribution.ts index 176a6edd5a897a..b19696cbff73bc 100644 --- a/src/vs/sessions/contrib/onboardingTours/browser/newSessionViewV2TourContribution.ts +++ b/src/vs/sessions/contrib/onboardingTours/browser/newSessionViewV2TourContribution.ts @@ -7,6 +7,7 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { ILogService } from '../../../../platform/log/common/log.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; import { IStorageService } from '../../../../platform/storage/common/storage.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { onboardingScenarioRegistry } from '../../../../workbench/contrib/onboarding/common/onboardingRegistry.js'; @@ -31,9 +32,11 @@ class NewSessionViewV2TourContribution extends Disposable implements IWorkbenchC @IChatEntitlementService chatEntitlementService: IChatEntitlementService, @IWorkbenchAssignmentService assignmentService: IWorkbenchAssignmentService, @ILogService logService: ILogService, + @IProductService productService: IProductService, ) { super(); + const enabledByDefault = productService.quality === 'insider'; const trigger = this._register(new NewSessionViewTourTrigger( NEW_SESSION_VIEW_V2_TOUR_ID, onboardingScenarioService, @@ -45,7 +48,11 @@ class NewSessionViewV2TourContribution extends Disposable implements IWorkbenchC )); this._register(onboardingScenarioRegistry.register(createNewSessionViewV2Tour( trigger.signal, - () => resolveNewSessionViewV2TourVariation(configurationService, assignmentService, logService), + () => resolveNewSessionViewV2TourVariation(configurationService, assignmentService, logService, { + defaultVariation: enabledByDefault ? 'workspaceAndModel' : 'default', + useTreatment: !enabledByDefault, + }), + { enabledByDefault }, ))); } } diff --git a/src/vs/sessions/contrib/onboardingTours/browser/newSessionViewV2TourVariation.ts b/src/vs/sessions/contrib/onboardingTours/browser/newSessionViewV2TourVariation.ts index f5f5c6c75b8612..514c9e7e2834fc 100644 --- a/src/vs/sessions/contrib/onboardingTours/browser/newSessionViewV2TourVariation.ts +++ b/src/vs/sessions/contrib/onboardingTours/browser/newSessionViewV2TourVariation.ts @@ -9,9 +9,16 @@ import { getOnboardingDeveloperModeVariation } from '../../../../workbench/contr import { IWorkbenchAssignmentService } from '../../../../workbench/services/assignment/common/assignmentService.js'; import { NEW_SESSION_VIEW_V2_TOUR_ID, NEW_SESSION_VIEW_V2_VARIATION_TREATMENT, NewSessionViewV2Variation } from './tours/newSessionViewV2Tour.js'; -export async function resolveNewSessionViewV2TourVariation(configurationService: IConfigurationService, assignmentService: IWorkbenchAssignmentService, logService: ILogService): Promise { +export async function resolveNewSessionViewV2TourVariation( + configurationService: IConfigurationService, + assignmentService: IWorkbenchAssignmentService, + logService: ILogService, + options: { defaultVariation?: NewSessionViewV2Variation; useTreatment?: boolean } = {}, +): Promise { const variation = getOnboardingDeveloperModeVariation(configurationService, NEW_SESSION_VIEW_V2_TOUR_ID) - ?? await assignmentService.getTreatment(NEW_SESSION_VIEW_V2_VARIATION_TREATMENT); + ?? (options.useTreatment !== false ? await assignmentService.getTreatment(NEW_SESSION_VIEW_V2_VARIATION_TREATMENT) : undefined) + ?? options.defaultVariation + ?? 'default'; if (variation === 'workspaceAndModel') { return variation; } diff --git a/src/vs/sessions/contrib/onboardingTours/browser/tours/newSessionViewV2Tour.ts b/src/vs/sessions/contrib/onboardingTours/browser/tours/newSessionViewV2Tour.ts index 7a3bda52e05fcf..31b339e7c8a411 100644 --- a/src/vs/sessions/contrib/onboardingTours/browser/tours/newSessionViewV2Tour.ts +++ b/src/vs/sessions/contrib/onboardingTours/browser/tours/newSessionViewV2Tour.ts @@ -73,7 +73,11 @@ const workspaceAndModelPayload: ISpotlightPayload = { }; /** Builds the interactive new-session view tour. */ -export function createNewSessionViewV2Tour(signal: IObservable, resolveVariation?: () => Promise): IOnboardingScenario { +export function createNewSessionViewV2Tour( + signal: IObservable, + resolveVariation?: () => Promise, + options: { enabledByDefault?: boolean } = {}, +): IOnboardingScenario { return { id: NEW_SESSION_VIEW_V2_TOUR_ID, seenKey: NEW_SESSION_ONBOARDING_SEEN_KEY, @@ -81,7 +85,7 @@ export function createNewSessionViewV2Tour(signal: IObservable, resolve when: createNewSessionViewRecentTourWhen(), trigger: { kind: 'observable', signal }, priority: 110, - experiment: NEW_SESSION_VIEW_V2_EXPERIMENT, + experiment: options.enabledByDefault ? undefined : NEW_SESSION_VIEW_V2_EXPERIMENT, presentation: { kind: SPOTLIGHT_PRESENTATION_KIND, payload: { diff --git a/src/vs/sessions/contrib/onboardingTours/test/browser/newSessionViewV2Tour.test.ts b/src/vs/sessions/contrib/onboardingTours/test/browser/newSessionViewV2Tour.test.ts index 3d3da50156ff94..3f482c66e7ed40 100644 --- a/src/vs/sessions/contrib/onboardingTours/test/browser/newSessionViewV2Tour.test.ts +++ b/src/vs/sessions/contrib/onboardingTours/test/browser/newSessionViewV2Tour.test.ts @@ -96,6 +96,12 @@ suite('NewSessionViewV2Tour', () => { ); }); + test('can be enabled by default without experiment gating', () => { + const trigger = observableValue(disposables, false); + + assert.strictEqual(createNewSessionViewV2Tour(trigger, undefined, { enabledByDefault: true }).experiment, undefined); + }); + test('resolves the workspace-and-model variation without changing the default flow', async () => { const trigger = observableValue(disposables, false); const scenario = createNewSessionViewV2Tour(trigger, async () => 'workspaceAndModel'); @@ -134,13 +140,14 @@ suite('NewSessionViewV2Tour', () => { test('selects the V2 variation from the experiment or an enabled developer override', async () => { const cases = [ - { treatment: undefined, developerMode: false, override: undefined, expected: 'default', warnings: 0 }, - { treatment: 'workspaceAndModel', developerMode: false, override: undefined, expected: 'workspaceAndModel', warnings: 0 }, - { treatment: 'default', developerMode: true, override: 'workspaceAndModel', expected: 'workspaceAndModel', warnings: 0 }, - { treatment: 'workspaceAndModel', developerMode: true, override: 'default', expected: 'default', warnings: 0 }, - { treatment: 'default', developerMode: false, override: 'workspaceAndModel', expected: 'default', warnings: 0 }, - { treatment: 'workspaceAndModel', developerMode: true, override: '', expected: 'workspaceAndModel', warnings: 0 }, - { treatment: 'unsupported', developerMode: false, override: undefined, expected: 'default', warnings: 1 }, + { treatment: undefined, developerMode: false, override: undefined, expected: 'default', warnings: 0, defaultVariation: undefined, useTreatment: undefined }, + { treatment: 'workspaceAndModel', developerMode: false, override: undefined, expected: 'workspaceAndModel', warnings: 0, defaultVariation: undefined, useTreatment: undefined }, + { treatment: 'default', developerMode: true, override: 'workspaceAndModel', expected: 'workspaceAndModel', warnings: 0, defaultVariation: undefined, useTreatment: undefined }, + { treatment: 'workspaceAndModel', developerMode: true, override: 'default', expected: 'default', warnings: 0, defaultVariation: undefined, useTreatment: undefined }, + { treatment: 'default', developerMode: false, override: 'workspaceAndModel', expected: 'default', warnings: 0, defaultVariation: undefined, useTreatment: undefined }, + { treatment: 'workspaceAndModel', developerMode: true, override: '', expected: 'workspaceAndModel', warnings: 0, defaultVariation: undefined, useTreatment: undefined }, + { treatment: 'unsupported', developerMode: false, override: undefined, expected: 'default', warnings: 1, defaultVariation: undefined, useTreatment: undefined }, + { treatment: 'default', developerMode: false, override: undefined, expected: 'workspaceAndModel', warnings: 0, defaultVariation: 'workspaceAndModel' as const, useTreatment: false }, ]; const results = []; for (const entry of cases) { @@ -160,7 +167,10 @@ suite('NewSessionViewV2Tour', () => { override warn(): void { warnings++; } }()); results.push({ - variation: await resolveNewSessionViewV2TourVariation(configurationService, assignmentService, logService), + variation: await resolveNewSessionViewV2TourVariation(configurationService, assignmentService, logService, { + defaultVariation: entry.defaultVariation, + useTreatment: entry.useTreatment, + }), warnings, requestedTreatments, }); @@ -168,7 +178,7 @@ suite('NewSessionViewV2Tour', () => { assert.deepStrictEqual(results, cases.map(entry => ({ variation: entry.expected, warnings: entry.warnings, - requestedTreatments: entry.developerMode && entry.override ? [] : ['onb.newSessionViewV2.variation'], + requestedTreatments: entry.developerMode && entry.override || entry.useTreatment === false ? [] : ['onb.newSessionViewV2.variation'], }))); }); 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 0b42285fb1422b..7cfcbbfe9d031d 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,8 @@ The cross-provider ownership, routing, persistence, and run-lifecycle contract i Within that contract, Agent Host providers expose the host's `ahp-automations://` channel when negotiated capabilities include Automations. `AgentHostAutomationStore` projects AHP state and maps host session resources into the local or remote Sessions resource scheme. `ReconnectableAgentHostAutomationStore` owns connection and capability transitions. The Agent Host owns execution, scheduling, and recovery without a renderer activation handshake; this provider owns only adaptation and connection-specific identity. Disconnected or unsupported hosts cannot fall back to a browser store or executor. +Standard run references resolve through the owning connection's advertised session identities. They wait for that metadata when necessary; editing an Automation's provider cannot change a historical run's frontend identity. + 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. @@ -67,6 +69,10 @@ The local provider uses: Agent provider names form logical session-type identifiers. Resource URI schemes remain the routing identity for content and model providers. Consumers must not derive one identifier by parsing another. +Native backend resources are immutable: historical provider-scheme sessions keep their addresses, while new allocations may use `ahp-session:/`. The advertised provider is separate from the backend resource. Both forms adapt to the same stable local `agent-host-` or remote `remote--` frontend scheme. Adapter caches retain the exact backend resource and provider across offline reloads; frontend state is not rekeyed. + +Native hosts and capable VS Code clients declare `_meta["vscode.ahpSessionUris"] = true`. Creation uses standard addressing when supported, or legacy addressing for an identified native host without that capability. New hosts declare `_meta["vscode.agentHost"] = true`; older hosts are identified by their validated root `hostBuild` metadata, without comparing version numbers. Unmarked conforming hosts remain standard AHP peers. Identified legacy VS Code connections see legacy sessions only; this does not change host state or project backend URI aliases onto the wire. Reconnection retains or renegotiates connection capabilities without readdressing any session. + ## Session adaptation `AgentHostSessionAdapter` is the stable `ISession` facade for a committed Agent Host session. It: @@ -89,7 +95,7 @@ Session workflow operations such as pull-request creation and Agent Merge are ad Chat catalogues with the same normalized effective working directories reference one session-scoped folder Branch Changes resource using the `ahp-folder-changeset:` scheme. The resource belongs to the folder/worktree scope rather than to a particular chat, so the default chat and matching peers advertise the same stable URI while chats in different folders or worktrees advertise different URIs. A multi-root workspace forms one composite scope, including when its folders contain multiple Git repositories, while distinct worktree paths remain separate scopes even when they come from the same repository. The selected base branch affects the diff computed for the resource but not its identity. The Agent Host resolves a representative chat as the folder scope's computation and persistence context, allowing matching chats to share branch computation, review state, monitoring, and picker content without making that chat the resource owner. -The containing session persists the latest Git state for each normalized effective-working-directory scope. Matching chats reuse that state when their catalogues are first published after restore, while separate sessions retain independent snapshots and live Git discovery remains authoritative. +The containing session persists the latest Git state for each normalized effective-working-directory scope. Matching chats reuse that state when their catalogues are first published after restore, while separate sessions retain independent snapshots and live Git discovery remains authoritative. Branch comparison baselines follow the destination scope: a relocated default chat must not inherit its former session folder's persisted baseline or Git-state fallback, while peers retain their own baselines. The compact session summary covers every chat workspace without copying chat-owned selectable catalogues onto the session. Folder isolation computes one Session Changes aggregate over the ordered union of session and chat roots. Worktree isolation combines one ready Branch Changes state per unique workspace scope, deduplicating files that overlap between multi-root scopes. Scopes known not to contain a Git repository are skipped. An incomplete restored scope or a transient Git lookup/diff failure preserves the last persisted complete summary until every Git-backed contributing state is ready. @@ -121,13 +127,49 @@ Automation drafts use the same `NewSession` implementation but are tracked separ Existing-session requests route by the provider resource and chat resource. Host notifications update adapters and catalog membership reactively. +### Folder-to-worktree conversion + +The Agent Host exposes `isolate_session` to supported main and peer chats. Despite its historical name, the operation isolates only the calling chat. It has no workspace argument and requires that chat to work in one local Git folder. Other chats remain in their existing folders, including chats sharing the caller's original folder. + +Subagents cannot invoke the tool. Copilot SDK-native workers can still inherit its definition because the SDK does not expose per-worker tool filtering; execution is rejected for worker and unknown tool-call origins. + +The host blocks new turns for the calling chat, waits for that chat's requesting turn to finish, then creates a fresh session-owned worktree. The calling chat retains its identity and history and automatically continues its original task in the worktree. + +A conversion starts a host-owned turn with a hidden request and reports its activity through the standard chat response progress stream. The turn completes with the workspace-transition notice or an error and is persisted locally. The host remains authoritative for admission: it rejects conflicting requests for the affected chat while a conversion is in progress, and clients add no gating of their own. Ordinary failures release the chat, while unsafe failures keep it quarantined, including after restoration. On success the notice survives history restoration, has no visible request row, and is anchored to the preceding provider turn for fork and truncate operations. + +Canceled or errored conversion turns retain queued requests. Clearing pending conversion state is not a queue-drain trigger: automatic draining remains owned by the existing per-chat successful turn-end lifecycle. + +Copilot and Codex update the exact chat backing and its durable working-directory metadata without moving other backings. Provider resume restores that chat's persisted directory rather than overwriting it with the aggregate session directory. The shared workspace customization anchor is persisted separately and also survives resume; multi-chat changes preserve it, while exclusive single-chat replacement updates it. Codex confirms live directory changes through the app-server settings update. An uncertain provider update retains the worktree and quarantines only the affected chat, including after restoration. + +Single-chat isolation does not require multi-root support. It replaces the session and chat working directories with the worktree, retains the source repository as the project, and adopts the normal session worktree configuration and lifecycle. Chat creation is serialized with this replacement; a chat created before conversion starts makes the single-chat request fail safely rather than moving the new chat. + +Multi-chat isolation requires the provider's multi-root capability. Other chats can continue running and new chats can be created. The aggregate session workspace retains its existing folders and includes the new checkout. Inheriting chats are pinned to their previous directories before it is added; the caller's chat state, catalog summary, persisted scope, and folder Git state then identify its new worktree. Session-wide isolation configuration remains unchanged. + +Git state follows the effective checkout: session-level refreshes use the aggregate session's primary directory, while chat-level refreshes use that chat's directory, including for the main chat. Destination branch metadata is published before its workspace is exposed, without waiting for the background refresh cooldown. The original folder is not modified; file inclusion follows ordinary worktree creation. + +### Changing a chat's workspace + +The `set_workspace` tool can change an existing workspace only at the user's explicit request; mentioning another path or repository is not a workspace-change request. Workspace-less quick chats retain the project-work setup flow. Promoting a workspace-less quick chat preserves its existing approval and mode selections in both live and persisted configuration. Both uses require the user-input confirmation of destination and isolation and the normal tool approval. The host waits for the calling turn to end, changes only that chat using the workspace transaction below, persists the transition, and continues the original task. Existing-workspace changes use the same provider eligibility and worktree restrictions as the isolation tool. + +Host-side no-op detection follows the host-local disk provider casing convention: case-sensitive on Linux and case-insensitive on Windows and macOS. It does not probe filesystem case sensitivity or resolve real paths or symlinks; client platform casing rules must not substitute for the host's convention. + +Workspace-less, folder-backed, and worktree-backed chats share the tool's transaction, trust checks, native host-owned progress turn, durable notice, and unsafe-change quarantine described above. Single-chat replacement does not require multi-root; workspace-less conversion is limited to single-chat sessions. Multi-chat changes require provider multi-root support and preserve sibling directories and the aggregate workspace. + +Single-chat workspace changes pass the exclusive workspace-replacement option to the provider's chat setter, including when the destination is an isolated worktree, so workspace customization discovery is reanchored to the new working directory. Multi-chat changes omit this option and retain the shared configuration scope, as with other multi-folder chats. This isolation applies equally to main and peer chats: changing either chat must not replace the shared customization discovery anchor or publish chat-local customizations as session-wide state. Both paths retain the chat setter's fail-closed handling of unconfirmed directory changes. + +When replacing a single chat's workspace, the previous session-owned worktree becomes an additional retained worktree owned by the same session. Its files are untouched and its cleanup remains part of the normal session lifecycle. Existing ownership is cleared only after transfer to retained ownership succeeds; unavailable creation metadata fails the transition while preserving live ownership for cleanup. The selected folder is never adopted as an owned worktree. Future worktree creation uses the new workspace rather than reusing the retained checkout. + +The Agents Window adapter follows the republished state for the same session. A project is applied only when it differs from the previous snapshot or arrives as an explicit delta, so a stale unchanged snapshot project cannot overwrite a newer one. Each authoritative isolation configuration also updates the worktree flag, including clearing it when a worktree session moves to a plain folder. + ## Persistence and discovery Startup metadata may seed lightweight session facades before a live connection finishes discovery. Live host state remains authoritative and upgrades or replaces cached state through the normal catalog lifecycle. +The host persists ordered aggregate workspace roots independently of each provider chat's working directory. Moving only the main chat in a multi-chat session preserves the aggregate primary root across listing and restart; exclusive workspace replacement replaces the aggregate roots. Legacy sessions without persisted host-owned roots fall back to the provider's workspace. + The provider remembers isolation per workspace after the first request is accepted. A new draft for that workspace inherits the choice from its last started session; a workspace without a remembered choice falls back to `sessions.useWorktree`. Explicitly removing a workspace from the workspace picker forgets its isolation preference; generic recent-workspace updates do not. Draft-only changes, rejected requests, quick chats, and Automation drafts do not update this workspace preference. -An Agent Host session may own additional detached worktrees for repositories beyond its primary workspace. The host persists each worktree's opaque handle, checkout path, and source repository root with the session. Archive, unarchive, automatic-deletion eligibility, and permanent deletion apply to every owned worktree; deleting session data resolves repository cleanup against source roots before removing the checkouts. +An Agent Host session may own additional detached worktrees for repositories beyond its primary workspace. The host persists each worktree's opaque handle, checkout path, and source repository root with the session. Detached peer worktree recovery uses the matching ownership record rather than the primary worktree's metadata. Archived-history loading may fall back to the source repository without overwriting the chat's persisted worktree working directory. Archive, unarchive, automatic-deletion eligibility, and permanent deletion apply to every owned worktree; deleting session data resolves repository cleanup against source roots before removing the checkouts. Before assigning an additional repository or folder to a chat, the host prepares its effective working directory. Folder isolation uses the requested directory directly. Worktree isolation resolves the primary repository through Git, reuses a session-owned checkout unless a fresh worktree is requested, or creates and claims a detached worktree. Before expanding the aggregate session workspace, the host pins chats that still inherit the complete workspace to their previous effective directories. The preparation operation returns the effective directory for the caller to assign explicitly to the target chat; tool argument parsing and relationship semantics remain separate from this lifecycle contract. diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts index 14cdb87d0e01d5..d10445f17c2584 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostAutomationStore.ts @@ -13,6 +13,7 @@ import { derived, type IObservable, observableSignalFromEvent, observableValue } import { basename, isEqual, isEqualOrParent, joinPath, relativePath } from '../../../../../base/common/resources.js'; import { hasKey } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; +import { AgentSession } from '../../../../../platform/agentHost/common/agent.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; import { localize } from '../../../../../nls.js'; import { type IAgentConnection } from '../../../../../platform/agentHost/common/agentService.js'; @@ -57,6 +58,8 @@ export interface IAgentHostAutomationBoundaryMapper { toHost(resource: URI): URI; fromHost(resource: URI): URI; resourceSchemeForProvider(provider: string): string; + sessionResource?(resource: URI): URI | undefined; + readonly onDidChangeSessionResolution?: Event; providerForSessionScheme?(scheme: string): string; providerForResourceScheme?(scheme: string): string | undefined; } @@ -106,6 +109,7 @@ 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); + const sessionResolutionChanged = observableSignalFromEvent(this, _boundaryMapper?.onDidChangeSessionResolution ?? Event.None); this.catalogueState = derived(this, reader => { this._catalogChanged.read(reader); this._catalogError.read(reader); @@ -120,6 +124,7 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro }); this.runs = derived(this, reader => { this._catalogChanged.read(reader); + sessionResolutionChanged.read(reader); return distinctById([...this._projectRuns(), ...this._archivedRuns.read(reader)]) .sort((first, second) => second.startedAt.localeCompare(first.startedAt)); }); @@ -300,9 +305,15 @@ export class AgentHostAutomationStore extends Disposable implements ISessionsPro } // Projects an Agent Host session resource into the editor-facing provider scheme. - private _projectSessionResource(resource: string): URI { + private _projectSessionResource(resource: string): URI | undefined { const session = URI.parse(resource); - const provider = this._boundaryMapper?.providerForSessionScheme?.(session.scheme) ?? session.scheme; + if (this._boundaryMapper?.sessionResource) { + return this._boundaryMapper.sessionResource(session); + } + const provider = this._boundaryMapper?.providerForSessionScheme?.(session.scheme) ?? AgentSession.provider(session); + if (!provider) { + return undefined; + } const resourceScheme = this._boundaryMapper?.resourceSchemeForProvider(provider); return resourceScheme ? session.with({ scheme: resourceScheme }) : session; } diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 6b26ff332388b8..283d20cbd16b16 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -11,7 +11,7 @@ import { getErrorMessage } from '../../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { IMarkdownString, MarkdownString, markdownStringEqual } from '../../../../../base/common/htmlContent.js'; import { Disposable, DisposableMap, DisposableStore, IDisposable, IReference, MutableDisposable, ReferenceCollection, toDisposable } from '../../../../../base/common/lifecycle.js'; -import { mapsStrictEqualIgnoreOrder, ResourceSet } from '../../../../../base/common/map.js'; +import { mapsStrictEqualIgnoreOrder, ResourceMap, ResourceSet } from '../../../../../base/common/map.js'; import { Schemas } from '../../../../../base/common/network.js'; import { deepClone, equals } from '../../../../../base/common/objects.js'; import { constObservable, derived, derivedOpts, IObservable, IReader, ISettableObservable, ITransaction, mapObservableArrayCached, observableFromEvent, observableSignal, observableSignalFromEvent, observableValueOpts, subtransaction, transaction, waitForState, autorun, observableValue } from '../../../../../base/common/observable.js'; @@ -20,6 +20,9 @@ import { themeColorFromId, ThemeIcon } from '../../../../../base/common/themable import { URI } from '../../../../../base/common/uri.js'; import { findDevContainerSample } from '../../../../../platform/agentHost/common/devContainerSamples.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; +import { newAgentHostSessionUri } from '../../../../../platform/agentHost/common/agentHostSessionIdentity.js'; +import { getAgentHostChatId } from '../../../../../platform/agentHost/common/agentHostChatIdentity.js'; +import { isNativeAgentHost } from '../../../../../platform/agentHost/common/meta/agentHostSessionUrisMeta.js'; import { localize } from '../../../../../nls.js'; import { AgentSession, AuthenticateParams, AuthenticateResult, CODEX_AGENT_PROVIDER_ID, type IAgentSessionChatMetadata, IAgentSessionMetadata, protectedResourcesRequireGitHubCopilotSignIn } from '../../../../../platform/agentHost/common/agent.js'; import { AgentMergeSessionOverrides, AgentMergeSessionState, readAgentMergeFolderState, readAgentMergeFolderStates } from '../../../../../platform/agentHost/common/agentMerge.js'; @@ -1465,13 +1468,13 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { ) { super(); const rawId = AgentSession.id(metadata.session); - const agentProvider = AgentSession.provider(metadata.session); + const agentProvider = metadata.provider ?? AgentSession.provider(metadata.session); if (!agentProvider) { throw new Error(`Agent session URI has no provider scheme: ${metadata.session.toString()}`); } this.agentProvider = agentProvider; - this.backendUri = AgentSession.uri(_options.backendSessionScheme ?? agentProvider, rawId); - this.resource = URI.from({ scheme: resourceScheme, path: `/${rawId}` }); + this.backendUri = metadata.session; + this.resource = metadata.session.with({ scheme: resourceScheme }); this._rawId = rawId; this._resourceScheme = resourceScheme; this.sessionId = toSessionId(providerId, this.resource); @@ -1765,10 +1768,10 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { if (!isChatInSessionReadAggregate(chat.chat.toString(), chat.origin, chat.interactivity)) { continue; } - const chatId = parseChatUri(chat.chat)?.chatId; - this._aggregateChatResources.add(chat.kind === 'default' || !chatId + const chatId = getAgentHostChatId(chat.chat); + this._aggregateChatResources.add(chat.kind === 'default' ? this.resource - : URI.from({ scheme: this._resourceScheme, path: `/${this._rawId}`, fragment: chatId })); + : this.resource.with({ fragment: chatId })); } const previousChats = this._chatsObs.get(); @@ -1784,8 +1787,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { const peerIds = chats .filter(chat => chat.kind === 'peer') - .map(chat => parseChatUri(chat.chat)?.chatId) - .filter(chatId => chatId !== undefined); + .map(chat => getAgentHostChatId(chat.chat)); const hasMultipleChats = peerIds.length > 0; const supportsChatReadState = this._supportsChatReadState.get(); this._hasMultipleChats.set(hasMultipleChats, tx); @@ -1813,10 +1815,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { ordered.push(this._defaultChat); continue; } - const chatId = parseChatUri(chat.chat)?.chatId; - if (!chatId) { - continue; - } + const chatId = getAgentHostChatId(chat.chat); let entry = this._additionalChats.get(chatId); if (!entry) { entry = this._createAdditionalChat(chatId, { @@ -1886,19 +1885,22 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { return undefined; } const defaultMetadata = chats?.find(chat => chat.kind === 'default'); - const currentDefaultMetadata: IAgentSessionChatMetadata = { + const defaultChat = defaultMetadata?.chat ?? this._defaultChatBackendUri.get(); + const currentDefaultMetadata: IAgentSessionChatMetadata | undefined = defaultChat ? { ...defaultMetadata, - chat: defaultMetadata?.chat ?? URI.parse(buildDefaultChatUri(this.backendUri)), + chat: defaultChat, summary: this._defaultChat.title.get(), kind: 'default', interactivity: toProtocolChatInteractivity(this._defaultChatInteractivity.get()), archived: this._defaultChat.isArchived.get(), isRead: this._defaultChat.isRead.get(), - }; + } : undefined; const result: IAgentSessionChatMetadata[] = []; for (const chat of this._chatsObs.get()) { if (chat === this._defaultChat) { - result.push(currentDefaultMetadata); + if (currentDefaultMetadata) { + result.push(currentDefaultMetadata); + } continue; } const entry = chat.resource.fragment ? this._additionalChats.get(chat.resource.fragment) : undefined; @@ -1906,7 +1908,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { result.push(entry.toMetadata()); } } - if (!result.includes(currentDefaultMetadata)) { + if (currentDefaultMetadata && !result.includes(currentDefaultMetadata)) { result.unshift(currentDefaultMetadata); } return result; @@ -1935,10 +1937,10 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { if (!isChatInSessionReadAggregate(chat.resource, chat.origin, chat.interactivity)) { continue; } - const chatId = parseChatUri(chat.resource)?.chatId; - this._aggregateChatResources.add(isDefault(chat) || !chatId + const chatId = getAgentHostChatId(chat.resource); + this._aggregateChatResources.add(isDefault(chat) ? this.resource - : URI.from({ scheme: this._resourceScheme, path: `/${this._rawId}`, fragment: chatId })); + : this.resource.with({ fragment: chatId })); } const defaultSummary = state.chats.find(isDefault); if (defaultSummary) { @@ -1956,7 +1958,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { // the peer-chat catalog even when the backing session type is otherwise // single-chat; the UI later decides whether to show them by default. const surfacesAsPeer = (summary: ChatSummary): boolean => { - const chatId = parseChatUri(summary.resource)?.chatId; + const chatId = getAgentHostChatId(summary.resource); return !isDefault(summary) && !!chatId && (this.capabilities.get().supportsMultipleChats @@ -1968,7 +1970,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { const survivingPeers = new Set(); for (const summary of state.chats) { if (surfacesAsPeer(summary)) { - survivingPeers.add(parseChatUri(summary.resource)!.chatId); + survivingPeers.add(getAgentHostChatId(summary.resource)); } } this._hasMultipleChats.set(survivingPeers.size > 0, undefined); @@ -2019,7 +2021,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { if (!surfacesAsPeer(summary)) { continue; } - const chatId = parseChatUri(summary.resource)!.chatId; + const chatId = getAgentHostChatId(summary.resource); let entry = this._additionalChats.get(chatId); if (!entry) { entry = this._createAdditionalChat(chatId, summary); @@ -2053,7 +2055,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { } private _createAdditionalChat(chatId: string, summary: AgentHostChatSummary): AdditionalChat { - const resource = URI.from({ scheme: this._resourceScheme, path: `/${this._rawId}`, fragment: chatId }); + const resource = this.resource.with({ fragment: chatId }); const backendUri = URI.parse(summary.resource); const output: IChatOutputObs = { lastTurnChanges: this._sessionOutput.getLastTurnChanges(backendUri), @@ -2502,7 +2504,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { applySessionStateMetadata(metadata: AgentHostSessionStateMetadata, previous: SessionState | undefined): boolean { let didChange = false; transaction(tx => { - if (metadata.project !== undefined || previous?.project !== undefined) { + if (Object.prototype.hasOwnProperty.call(metadata, 'project')) { this._project = metadata.project; } if (metadata.workingDirectories !== undefined || previous?.workingDirectories !== undefined) { @@ -2939,6 +2941,7 @@ interface INewSessionConstructionContext { * takes over ownership of the same `sessionId` key. */ readonly onSessionState?: (sessionId: string, state: SessionState | undefined) => void; + readonly onSessionCreated: (backendSession: URI) => void; readonly activeClientScope: IAgentCustomizationScope; } @@ -3070,6 +3073,7 @@ class NewSession extends Disposable { */ private readonly _activeClientPublisher = this._register(new MutableDisposable()); private readonly _onSessionState: ((sessionId: string, state: SessionState | undefined) => void) | undefined; + private readonly _onSessionCreated: INewSessionConstructionContext['onSessionCreated']; private readonly _activeClientScope: IAgentCustomizationScope; private readonly _initialMetadata: Record | undefined; @@ -3112,6 +3116,7 @@ class NewSession extends Disposable { this._providerId = ctx.providerId; this._logService = ctx.logService; this._onSessionState = ctx.onSessionState; + this._onSessionCreated = ctx.onSessionCreated; this._activeClientScope = ctx.activeClientScope; this._register(this._activeClientScope); this._initialMetadata = ctx.initialMetadata; @@ -3607,7 +3612,7 @@ class NewSession extends Disposable { } const activeClient = this._activeClientScope.activeClient(connection.clientId).get(); createdWithActiveClient = activeClient; - await connection.createSession({ + const createdSession = await connection.createSession({ provider: this.agentProvider, session: backendUri, ...(this._selectedModelId ? { model: this.getSelectedModel() } : {}), @@ -3623,6 +3628,9 @@ class NewSession extends Disposable { ...(this._selectedAgent ? { agent: { uri: this._selectedAgent.uri } } : {}), activeClient, }); + if (!isEqual(createdSession, backendUri)) { + throw new Error(`Agent host returned unexpected session URI: ${createdSession.toString()}`); + } } catch (err) { this._logService.warn(`[${this._providerId}] Eager createSession failed for ${backendUri.toString()}: ${err}`); // Clear backend bookkeeping so a later `dispose()` doesn't @@ -3642,6 +3650,7 @@ class NewSession extends Disposable { if (this._backendUri?.toString() !== backendUri.toString()) { return; } + this._onSessionCreated(backendUri); // Hold a state subscription for our lifetime so the agent host's // empty-session GC sees a non-zero subscriber count. The session @@ -3869,8 +3878,9 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement */ protected readonly _lastSessionStates = new Map(); - /** Cache of adapted sessions, keyed by raw session ID. */ + /** Cache of adapted sessions, keyed by exact backend resource. */ protected readonly _sessionCache = new Map(); + private readonly _sessionKeysByResource = new ResourceMap(); protected _refreshSessionWorkspaces(): void { const changed = [...this._sessionCache.values()].filter(session => session.refreshWorkspace()); @@ -3889,11 +3899,11 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement /** * Snapshot of the source metadata for each adapter in {@link _sessionCache}, - * keyed by raw session ID. Captured in {@link createAdapter}/{@link updateAdapter} + * keyed by exact backend resource. Captured in {@link createAdapter}/{@link updateAdapter} * and re-used by {@link _persistCache} to serialize sessions without having to * reconstruct every `IAgentSessionMetadata` field from observables. */ - private readonly _metaByRawId = new Map(); + private readonly _metadataBySession = new Map(); /** * Set when {@link _sessionCache} has changed since the last persist. The @@ -3939,16 +3949,20 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } protected _getBackendSessionUri(sessionId: string): URI | undefined { - const rawId = this._rawIdFromChatId(sessionId); + const draft = this._newSessions.get(sessionId); + if (draft) { + return draft.backendUri; + } + const rawId = this._sessionKeyFromChatId(sessionId); if (!rawId) { return undefined; } - return this._sessionCache.get(rawId)?.backendUri ?? this._newSessions.get(sessionId)?.backendUri; + return this._sessionCache.get(rawId)?.backendUri; } protected _hasSession(sessionId: string): boolean { - const rawId = this._rawIdFromChatId(sessionId); + const rawId = this._sessionKeyFromChatId(sessionId); return !!rawId && this._sessionCache.has(rawId); } @@ -3980,12 +3994,12 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (draft) { return draft.initialMetadata; } - const rawId = this._rawIdFromChatId(sessionId); - return rawId ? this._metaByRawId.get(rawId)?._meta : undefined; + const rawId = this._sessionKeyFromChatId(sessionId); + return rawId ? this._metadataBySession.get(rawId)?._meta : undefined; } - protected _getSessionMetadataByRawId(rawId: string): Record | undefined { - return this._metaByRawId.get(rawId)?._meta; + protected _getSessionMetadataByKey(rawId: string): Record | undefined { + return this._metadataBySession.get(rawId)?._meta; } /** Full resolved config (schema + values) for running sessions, keyed by session ID. */ @@ -4118,6 +4132,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement cached.dispose(); } this._sessionCache.clear(); + this._sessionKeysByResource.clear(); })); // Keep the state subscription of every on-screen session pinned so @@ -4146,9 +4161,9 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement this._cacheDirty = true; } for (const removed of e.removed) { - const rawId = this._rawIdFromChatId(removed.sessionId); - if (rawId) { - this._metaByRawId.delete(rawId); + if (removed instanceof AgentHostSessionAdapter) { + this._metadataBySession.delete(removed.backendUri.toString()); + this._sessionKeysByResource.delete(removed.resource.with({ fragment: '' })); } } })); @@ -4169,6 +4184,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement /** Provider-level authentication-pending observable used to derive `loading` for sessions. */ protected abstract get authenticationPending(): IObservable; + protected abstract registerBackendSession(backendSession: URI, provider: string): void; /** Connection state for remote-host sessions. */ protected get remoteConnectionStatus(): IObservable | undefined { @@ -4196,16 +4212,11 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } /** - * The backend (wire) session URI scheme for a given agent provider. Default is - * identity (scheme == provider), which holds for every host except the Copilot - * host used by cloud sandbox, whose sessions are addressed under - * `ahp-session:/` while the agent provider is `copilot`. Subclasses - * override this so all backend `AgentSession.uri(...)` reconstructions on the - * adapter and provider use the host's real scheme. Must be a stable per-provider - * mapping. + * Creation addressing for a new draft, negotiated with its owning host. + * Committed sessions always use their exact advertised resource. */ protected _backendSessionScheme(agentProvider: string): string { - return agentProvider; + return newAgentHostSessionUri(agentProvider, '', this.connection?.initializeResult.get()).scheme; } protected _logicalSessionTypeForBackendScheme(backendScheme: string): string { @@ -4213,16 +4224,24 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } private _mapBackendSessionResource(resource: URI): URI { - const sessionType = this._logicalSessionTypeForBackendScheme(resource.scheme); + const known = this._sessionCache.get(resource.toString()); + if (known) { + return known.resource; + } + const sessionType = this._metadataBySession.get(resource.toString())?.provider ?? AgentSession.provider(resource); + if (!sessionType) { + throw new Error(`Cannot project session without its advertised provider: ${resource.toString()}`); + } return resource.with({ scheme: this.resourceSchemeForProvider(sessionType) }); } /** Build an adapter for the given metadata. */ protected createAdapter(meta: IAgentSessionMetadata): AgentHostSessionAdapter { - const provider = AgentSession.provider(meta.session); + const provider = meta.provider ?? AgentSession.provider(meta.session); if (!provider) { throw new Error(`Agent session URI has no provider scheme: ${meta.session.toString()}`); } + this.registerBackendSession(meta.session, provider); const resourceScheme = this.resourceSchemeForProvider(provider); const options = { @@ -4245,9 +4264,11 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement ...this._adapterOptions(), } satisfies IAgentHostAdapterOptions; - const rawId = AgentSession.id(meta.session); - this._metaByRawId.set(rawId, meta); - return this._instantiationService.createInstance(AgentHostSessionAdapter, meta, this.id, resourceScheme, provider, options, this._getChatCatalogLoading(rawId), sessionId => this._acquireSessionChatDetails(sessionId)); + const rawId = meta.session.toString(); + this._metadataBySession.set(rawId, meta); + const adapter = this._instantiationService.createInstance(AgentHostSessionAdapter, meta, this.id, resourceScheme, provider, options, this._getChatCatalogLoading(rawId), sessionId => this._acquireSessionChatDetails(sessionId)); + this._sessionKeysByResource.set(adapter.resource.with({ fragment: '' }), rawId); + return adapter; } private _getChatCatalogLoading(rawId: string): ISettableObservable { @@ -4260,14 +4281,14 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } protected updateAdapter(adapter: AgentHostSessionAdapter, meta: IAgentSessionMetadata): boolean { - const rawId = AgentSession.id(meta.session); + const rawId = meta.session.toString(); if (meta.status !== undefined) { const status = withSessionStatusFlag(meta.status, ProtocolSessionStatus.IsArchived, this._resolveArchivedState(rawId, isSessionStatusArchived(meta.status))); if (status !== meta.status) { meta = { ...meta, status }; } } - this._metaByRawId.set(rawId, meta); + this._metadataBySession.set(rawId, meta); this._cacheDirty = true; return adapter.update(meta); } @@ -4439,7 +4460,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement return; } - const rawId = this._rawIdFromChatId(activeSession.sessionId); + const rawId = this._sessionKeyFromChatId(activeSession.sessionId); const cached = rawId ? this._sessionCache.get(rawId) : undefined; const connection = this.connection; if (!rawId || !cached || !connection) { @@ -4486,7 +4507,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement return; } - connection.dispatch(AgentSession.uri(cached.agentProvider, rawId).toString(), { + connection.dispatch(cached.backendUri.toString(), { type: ActionType.SessionActiveClientSet, activeClient, }); @@ -4820,6 +4841,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement initialModelConfiguration, initialConfigSchema: this._seededConfigSchema(), initialMetadata, + onSessionCreated: backendSession => this.registerBackendSession(backendSession, sessionType.id), instantiationService: this._instantiationService, onSessionState: (id, state) => state === undefined ? this._handleNewSessionStateGone(id) @@ -5181,7 +5203,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (draft) { return draft.modelConfiguration; } - const rawId = this._rawIdFromChatId(sessionId); + const rawId = this._sessionKeyFromChatId(sessionId); if (!this._runningModelConfigurations.has(sessionId) && rawId && this._sessionCache.has(rawId)) { this._runningModelConfigurations.set(sessionId, new AutomationModelConfiguration(this._languageModelsService)); } @@ -5426,7 +5448,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement this._onDidChangeSessionConfig.fire(sessionId); // Dispatch to the agent host - const rawId = this._rawIdFromChatId(sessionId); + const rawId = this._sessionKeyFromChatId(sessionId); const cached = rawId ? this._sessionCache.get(rawId) : undefined; if (cached && rawId) { const sessionUri = cached.backendUri; @@ -5501,7 +5523,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement this._onDidChangeSessionConfig.fire(sessionId); // Dispatch to the agent host with replace semantics. - const rawId = this._rawIdFromChatId(sessionId); + const rawId = this._sessionKeyFromChatId(sessionId); const cached = rawId ? this._sessionCache.get(rawId) : undefined; if (cached && rawId) { const sessionUri = cached.backendUri; @@ -5516,7 +5538,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } getAgentMergeSessionState(sessionId: string, chat?: URI): AgentMergeSessionState | undefined { - const rawId = this._rawIdFromChatId(sessionId); + const rawId = this._sessionKeyFromChatId(sessionId); const cached = rawId ? this._sessionCache.get(rawId) : undefined; if (!cached) { const legacy = chat ? undefined : readAgentMergeFolderState(this._lastSessionStates.get(sessionId)?.config?.values, undefined, undefined); @@ -5594,7 +5616,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement const stateChanged = observableSignalFromEvent(this, observedEvent); const observable = derivedOpts({ owner: this, equalsFn: structuralEquals }, reader => { stateChanged.read(reader); - const rawId = this._rawIdFromChatId(sessionId); + const rawId = this._sessionKeyFromChatId(sessionId); const cached = rawId ? this._sessionCache.get(rawId) : undefined; const state = cached ? this._getAgentMergeSessionState(sessionId, cached.getAgentMergeFolder(chat, reader)) @@ -5625,7 +5647,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } private async _writeAgentMergeClientState(sessionId: string, enabled: boolean, overrides: AgentMergeSessionOverrides | undefined, chat: URI | undefined): Promise { - const rawId = this._rawIdFromChatId(sessionId); + const rawId = this._sessionKeyFromChatId(sessionId); const cached = rawId ? this._sessionCache.get(rawId) : undefined; const connection = this.connection; if (!rawId || !cached || !connection) { @@ -6001,7 +6023,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (newSession) { return newSession.session.resource.scheme; } - const rawId = this._rawIdFromChatId(sessionId); + const rawId = this._sessionKeyFromChatId(sessionId); const cached = rawId ? this._sessionCache.get(rawId) : undefined; return cached?.resource.scheme; } @@ -6020,7 +6042,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement return; } - const rawId = this._rawIdFromChatId(sessionId); + const rawId = this._sessionKeyFromChatId(sessionId); const cached = rawId ? this._sessionCache.get(rawId) : undefined; const connection = this.connection; if (cached && rawId && connection) { @@ -6040,7 +6062,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement return; } - const rawId = this._rawIdFromChatId(sessionId); + const rawId = this._sessionKeyFromChatId(sessionId); const cached = rawId ? this._sessionCache.get(rawId) : undefined; const connection = this.connection; if (cached && rawId && connection) { @@ -6086,8 +6108,8 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement const sessionId = toSessionId(this.id, sessionResource); const state = this._lastSessionStates.get(sessionId); if (!state) { - const rawId = this._rawIdFromChatId(sessionId); - return rawId ? this._sessionCache.get(rawId)?.getBackendChatResource(chatResource) : undefined; + const key = this._sessionKeyFromChatId(sessionId); + return key ? this._sessionCache.get(key)?.getBackendChatResource(chatResource) : undefined; } // Look up the authoritative host-supplied backend chat URI rather than // constructing one: a peer chat's client fragment is exactly the chatId of @@ -6207,7 +6229,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (!connection) { return undefined; } - const rawId = this._rawIdFromChatId(sessionId); + const rawId = this._sessionKeyFromChatId(sessionId); const cached = rawId ? this._sessionCache.get(rawId) : undefined; if (!cached || !rawId) { return undefined; @@ -6224,7 +6246,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } async importSession(sessionId: string): Promise { - const rawId = this._rawIdFromChatId(sessionId); + const rawId = this._sessionKeyFromChatId(sessionId); const cached = rawId ? this._sessionCache.get(rawId) : undefined; const connection = this.connection; if (!cached || !connection?.importSession || !supportsAgentHostSessionImport(connection.initializeResult.get())) { @@ -6236,7 +6258,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } async getSessionWorktreeDiskUsage(sessionId: string): Promise { - const rawId = this._rawIdFromChatId(sessionId); + const rawId = this._sessionKeyFromChatId(sessionId); const session = rawId ? this._sessionCache.get(rawId) : undefined; const connection = this.connection; if (!session || !connection) { @@ -6273,7 +6295,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement private async _setChatArchived(sessionId: string, chatResource: URI, archived: boolean): Promise { const chatId = chatResource.fragment; - const rawId = this._rawIdFromChatId(sessionId); + const rawId = this._sessionKeyFromChatId(sessionId); const cached = rawId ? this._sessionCache.get(rawId) : undefined; const connection = this.connection; const initializeResult = connection?.initializeResult.get(); @@ -6299,7 +6321,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement * the change can never be recorded is worse than appearing not to archive. */ protected _setSessionArchived(sessionId: string, isArchived: boolean): boolean { - const rawId = this._rawIdFromChatId(sessionId); + const rawId = this._sessionKeyFromChatId(sessionId); const cached = rawId ? this._sessionCache.get(rawId) : undefined; const connection = this.connection; if (!cached || !rawId || !connection) { @@ -6311,7 +6333,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } protected _setSessionArchivedLocally(sessionId: string, isArchived: boolean): boolean { - const rawId = this._rawIdFromChatId(sessionId); + const rawId = this._sessionKeyFromChatId(sessionId); const cached = rawId ? this._sessionCache.get(rawId) : undefined; if (!cached) { return false; @@ -6322,7 +6344,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } async setSessionReadState(sessionId: string, isRead: boolean): Promise { - const rawId = this._rawIdFromChatId(sessionId); + const rawId = this._sessionKeyFromChatId(sessionId); const cached = rawId ? this._sessionCache.get(rawId) : undefined; if (cached && rawId) { if (!cached.supportsChatReadState()) { @@ -6351,7 +6373,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } async setChatReadState(sessionId: string, chatResource: URI, isRead: boolean): Promise { - const rawId = this._rawIdFromChatId(sessionId); + const rawId = this._sessionKeyFromChatId(sessionId); const cached = rawId ? this._sessionCache.get(rawId) : undefined; const connection = this.connection; const initializeResult = connection?.initializeResult.get(); @@ -6406,7 +6428,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } const targets: { rawId: string; cached: AgentHostSessionAdapter }[] = []; for (const sessionId of sessionIds) { - const rawId = this._rawIdFromChatId(sessionId); + const rawId = this._sessionKeyFromChatId(sessionId); const cached = rawId ? this._sessionCache.get(rawId) : undefined; if (cached && rawId) { targets.push({ rawId, cached }); @@ -6435,13 +6457,12 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } async renameChat(sessionId: string, chatUri: URI, title: string): Promise { - const rawId = this._rawIdFromChatId(sessionId); + const rawId = this._sessionKeyFromChatId(sessionId); const cached = rawId ? this._sessionCache.get(rawId) : undefined; const connection = this.connection; if (!cached || !rawId) { return; } - const sessionUri = cached.backendUri; const chatId = chatUri.fragment; if (!chatId && this._adapterOptions().useSessionTitleForDefaultChat) { return this.renameSession(sessionId, title); @@ -6449,23 +6470,28 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (!connection) { return; } + const backendChat = this.getBackendChatResource(chatUri) + ?? (!chatId && isNativeAgentHost(connection.initializeResult.get()) ? URI.parse(buildDefaultChatUri(cached.backendUri)) : undefined); + if (!backendChat) { + throw new Error(localize('chatNotFound', "The chat could not be found.")); + } const action = { type: ActionType.SessionTitleChanged as const, title }; if (chatId) { // Additional peer chat: rename only that chat by dispatching on its // chat channel. The host translates this to a per-chat update. cached.setAdditionalChatTitle(chatId, title); - connection.dispatch(buildChatUri(sessionUri, chatId), action); + connection.dispatch(backendChat.toString(), action); } else { // Default chat: rename the default chat tab independently of the // session title by dispatching on the default chat channel. cached.setDefaultChatTitle(title); - connection.dispatch(buildDefaultChatUri(sessionUri), action); + connection.dispatch(backendChat.toString(), action); } this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); } async renameSession(sessionId: string, title: string): Promise { - const rawId = this._rawIdFromChatId(sessionId); + const rawId = this._sessionKeyFromChatId(sessionId); const cached = rawId ? this._sessionCache.get(rawId) : undefined; const connection = this.connection; if (cached && rawId && connection) { @@ -6478,7 +6504,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } async removeSessionArtifact(sessionId: string, artifactId: string): Promise { - const rawId = this._rawIdFromChatId(sessionId); + const rawId = this._sessionKeyFromChatId(sessionId); const cached = rawId ? this._sessionCache.get(rawId) : undefined; const connection = this.connection; if (!cached || !connection?.removeSessionArtifact || !supportsAgentHostArtifactRemoval(connection.initializeResult.get())) { @@ -6494,14 +6520,16 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // deleted in isolation. return false; } - const rawId = this._rawIdFromChatId(sessionId); + const rawId = this._sessionKeyFromChatId(sessionId); const cached = rawId ? this._sessionCache.get(rawId) : undefined; const connection = this.connection; if (!rawId || !cached || !connection) { return false; } - const sessionUri = cached.backendUri; - const ahpChatUri = URI.parse(buildChatUri(sessionUri, chatId)); + const ahpChatUri = this.getBackendChatResource(chatUri); + if (!ahpChatUri) { + throw new Error(localize('chatNotFound', "The chat could not be found.")); + } if (!options?.skipConfirmation) { const confirmed = await this._dialogService.confirm({ @@ -6542,7 +6570,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } private async _createAdditionalChat(chatId: string, connection: IAgentConnection): Promise { - const rawId = this._rawIdFromChatId(chatId); + const rawId = this._sessionKeyFromChatId(chatId); const cached = rawId ? this._sessionCache.get(rawId) : undefined; if (!rawId || !cached) { throw new Error(`Session '${chatId}' not found`); @@ -6585,7 +6613,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (!connection) { throw new Error(this._notConnectedSendErrorMessage()); } - const rawId = this._rawIdFromChatId(sessionId); + const rawId = this._sessionKeyFromChatId(sessionId); const cached = rawId ? this._sessionCache.get(rawId) : undefined; if (!rawId || !cached) { throw new Error(`Session '${sessionId}' not found`); @@ -6634,7 +6662,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (!connection) { throw new Error(this._notConnectedSendErrorMessage()); } - const rawId = this._rawIdFromChatId(sessionId); + const rawId = this._sessionKeyFromChatId(sessionId); const cached = rawId ? this._sessionCache.get(rawId) : undefined; if (!rawId || !cached) { throw new Error(`Session '${sessionId}' not found`); @@ -6705,11 +6733,18 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } private _resolveBackendSourceChatUri(sessionId: string, sessionUri: URI, sourceChat: URI): URI { + const advertised = this.getBackendChatResource(sourceChat); + if (advertised) { + return advertised; + } const state = this._lastSessionStates.get(sessionId); const backendResource = state && getSessionChatResource(state, sourceChat.fragment || DEFAULT_CHAT_ID); if (backendResource) { return URI.parse(backendResource); } + if (!isNativeAgentHost(this.connection?.initializeResult.get())) { + throw new Error(localize('chatNotFound', "The chat could not be found.")); + } if (sourceChat.fragment) { const subagentPrefix = 'subagent/'; if (sourceChat.fragment.startsWith(subagentPrefix)) { @@ -6731,7 +6766,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement /** Send the first request for an already-committed peer chat, then clear its `new` flag. */ private async _sendCommittedChatRequest(chatId: string, chatResource: URI, options: ISendRequestOptions): Promise { - const rawId = this._rawIdFromChatId(chatId); + const rawId = this._sessionKeyFromChatId(chatId); const cached = rawId ? this._sessionCache.get(rawId) : undefined; if (!rawId || !cached) { throw new Error(`Session '${chatId}' not found`); @@ -6972,7 +7007,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (options.title) { await this.renameSession(committedSession.sessionId, options.title); } - const committedRawIdForSelections = this._rawIdFromChatId(committedSession.sessionId); + const committedRawIdForSelections = this._sessionKeyFromChatId(committedSession.sessionId); const committedAdapter = committedRawIdForSelections ? this._sessionCache.get(committedRawIdForSelections) : undefined; if (committedAdapter && selectedModelId) { this._preserveNewSessionModelSelection(committedAdapter, selectedModelId, selectedModelSource, selectedModelConfiguration); @@ -7059,18 +7094,19 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement committedAdapter.setChatModelId(committedAdapter.resource, modelId, source); } - protected _rawIdFromChatId(chatId: string): string | undefined { + protected _sessionKeyFromChatId(chatId: string): string | undefined { const prefix = `${this.id}:`; const resourceStr = chatId.startsWith(prefix) ? chatId.substring(prefix.length) : chatId; try { - return URI.parse(resourceStr).path.substring(1) || undefined; + const resource = URI.parse(resourceStr).with({ fragment: '' }); + return this._sessionKeysByResource.get(resource); } catch { return undefined; } } private _acquireSessionChatDetails(sessionId: string): IDisposable { - const rawId = this._rawIdFromChatId(sessionId); + const rawId = this._sessionKeyFromChatId(sessionId); if (!rawId || !this._sessionCache.has(rawId)) { return Disposable.None; } @@ -7109,9 +7145,9 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement return; } const connection = this.connection; - const rawId = this._rawIdFromChatId(sessionId); + const rawId = this._sessionKeyFromChatId(sessionId); const cached = rawId ? this._sessionCache.get(rawId) : undefined; - if (!connection || !rawId || !cached || readSessionEhcliAdoptable(this._metaByRawId.get(rawId)?._meta)) { + if (!connection || !rawId || !cached || readSessionEhcliAdoptable(this._metadataBySession.get(rawId)?._meta)) { return; } const ref = connection.getSubscription(StateComponents.Session, cached.backendUri, 'BaseAgentHostSessionsProvider.agentMergeState'); @@ -7221,7 +7257,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (!parsedChat) { return; } - const cached = this._sessionCache.get(AgentSession.id(parsedChat.session)); + const cached = this._sessionCache.get(parsedChat.session); if (cached) { this._keepSessionStateAlive(cached.sessionId); } @@ -7244,7 +7280,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (!connection) { return; } - const rawId = this._rawIdFromChatId(sessionId); + const rawId = this._sessionKeyFromChatId(sessionId); if (!rawId) { return; } @@ -7253,7 +7289,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // agent-host restore, which adopts (migrates) it. Migration must happen // only when the user explicitly opens the session. It renders read-only // from its summary until then; the marker clears once it is adopted. - if (readSessionEhcliAdoptable(this._metaByRawId.get(rawId)?._meta)) { + if (readSessionEhcliAdoptable(this._metadataBySession.get(rawId)?._meta)) { return; } const cached = this._sessionCache.get(rawId); @@ -7416,7 +7452,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement this._onDidChangeSessionConfig.fire(sessionId); } this._applySessionMetadataFromState(sessionId, state, previous); - const rawId = this._rawIdFromChatId(sessionId); + const rawId = this._sessionKeyFromChatId(sessionId); this._applyChatCatalogFromState(sessionId, state); if (rawId) { this._chatCatalogLoading.get(rawId)?.set(false, undefined); @@ -7435,7 +7471,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement * agents. See {@link AgentHostSessionAdapter.reconcileSelectedAgent}. */ private _reconcileAgentFromState(sessionId: string, state: SessionState): void { - const rawId = this._rawIdFromChatId(sessionId); + const rawId = this._sessionKeyFromChatId(sessionId); if (!rawId) { return; } @@ -7453,7 +7489,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement * re-render reactively. */ private _applyChatCatalogFromState(sessionId: string, state: SessionState): void { - const rawId = this._rawIdFromChatId(sessionId); + const rawId = this._sessionKeyFromChatId(sessionId); if (!rawId) { return; } @@ -7541,7 +7577,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } private _applySessionMetadataFromState(sessionId: string, state: SessionState, previous: SessionState | undefined): void { - const rawId = this._rawIdFromChatId(sessionId); + const rawId = this._sessionKeyFromChatId(sessionId); if (!rawId) { return; } @@ -7551,10 +7587,13 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } const metadata: AgentHostSessionStateMetadata = { - project: state.project ? { - displayName: state.project.displayName, - uri: this.mapProjectUri(URI.parse(state.project.uri)), - } : undefined, + // Unchanged snapshot fields must not overwrite newer catalogue deltas. + ...(!equals(state.project, previous?.project) ? { + project: state.project ? { + displayName: state.project.displayName, + uri: this.mapProjectUri(URI.parse(state.project.uri)), + } : undefined, + } : {}), workingDirectories: state.workingDirectories?.map(directory => this.mapWorkingDirectoryUri(URI.parse(directory))), _meta: state._meta, }; @@ -7618,12 +7657,10 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement private _applyWorktreeIsolation(sessionId: string, values: Record | undefined): void { const config = this._runningSessionConfigs.get(sessionId); const isolation = config && getSessionWorkspaceProperties(config.schema).isolation; - if (!(isolation ? readSessionIsolation(isolation, values?.[isolation.key]) === 'worktree' : isWorktreeIsolation(values))) { - return; - } - const rawId = this._rawIdFromChatId(sessionId); + const isolated = isolation ? readSessionIsolation(isolation, values?.[isolation.key]) === 'worktree' : isWorktreeIsolation(values); + const rawId = this._sessionKeyFromChatId(sessionId); const adapter = rawId ? this._sessionCache.get(rawId) : undefined; - adapter?.setWorktreeIsolation(true); + adapter?.setWorktreeIsolation(isolated); } // -- Session cache management -------------------------------------------- @@ -7650,7 +7687,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement /** * Whether {@link _onDidChangeSessions} events should update the persistence - * bookkeeping ({@link _cacheDirty} + {@link _metaByRawId}). Default `true`; + * bookkeeping ({@link _cacheDirty} + {@link _metadataBySession}). Default `true`; * the remote provider overrides this to suspend tracking while its cached * sessions are unpublished (offline), so the on-disk snapshot survives. */ @@ -7658,6 +7695,10 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement return true; } + protected _adoptCachedSessionMeta(meta: IAgentSessionMetadata): IAgentSessionMetadata { + return this._adoptSessionMeta(meta); + } + /** Load persisted session summaries into {@link _sessionCache}. */ private _loadCachedSessions(): void { if (!this._sessionCacheStorageKey) { @@ -7672,8 +7713,8 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (!deserialized) { continue; } - const meta = this._adoptSessionMeta(deserialized); - const rawId = AgentSession.id(meta.session); + const meta = this._adoptCachedSessionMeta(deserialized); + const rawId = meta.session.toString(); if (this._sessionCache.has(rawId)) { continue; } @@ -7687,7 +7728,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement * Persist the current {@link _sessionCache} to storage, capping at * {@link CACHED_SESSIONS_MAX_PER_HOST} most-recently-modified entries. * Mutable fields are read from each adapter's observables and overlaid on - * top of the original metadata snapshot captured in {@link _metaByRawId}. + * top of the original metadata snapshot captured in {@link _metadataBySession}. */ private _persistCache(): void { if (!this._sessionCacheStorageKey) { @@ -7695,7 +7736,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } const entries: ISerializedSessionMetadata[] = []; for (const [rawId, adapter] of this._sessionCache) { - const base = this._metaByRawId.get(rawId); + const base = this._metadataBySession.get(rawId); if (!base) { continue; } @@ -7771,9 +7812,9 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement for (const rawMeta of sessions) { const meta = this._adoptSessionMeta(rawMeta); - const rawId = AgentSession.id(meta.session); + const rawId = meta.session.toString(); currentKeys.add(rawId); - const agentProvider = AgentSession.provider(meta.session); + const agentProvider = meta.provider ?? AgentSession.provider(meta.session); if (agentProvider) { listedAgentProviders.add(agentProvider); } @@ -7798,7 +7839,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // Some hosts briefly omit just-sent eager sessions from listSessions. // Keep pending sessions visible until their sendRequest graduates them. const pendingRawIds = new Set([...this._pendingSessions.values()] - .map(session => session.resource.path.replace(/^\//, ''))); + .map(session => session.resource.toString())); // The host aggregates one listing across all of its agents, and an // agent that cannot enumerate yet (its SDK is not downloaded) can // contribute an empty list rather than failing. When other agents @@ -7812,7 +7853,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement const evictUnlistedAgents = listedAgentProviders.size === 0; for (const [key, cached] of this._sessionCache) { if (!currentKeys.has(key)) { - if (pendingRawIds.has(key)) { + if (pendingRawIds.has(cached.resource.toString())) { continue; } if (!this._isSessionEvictable(key)) { @@ -7904,7 +7945,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement const matches = (session: ISession): boolean => session.resource.scheme === expectedScheme && session.resource.path.substring(1) === ownRawId; await this._refreshSessions(); - const immediate = this._sessionCache.get(ownRawId); + const immediate = [...this._sessionCache.values()].find(matches); if (immediate && matches(immediate)) { return immediate; } @@ -8001,7 +8042,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // Adopt before deriving the cache key so a host that addresses sessions under a different // scheme routes to the agent provider, as the refresh and persistence paths do. const meta = this._adoptSessionMeta(rawMeta); - const rawId = AgentSession.id(meta.session); + const rawId = meta.session.toString(); const existing = this._sessionCache.get(rawId); if (existing) { @@ -8019,7 +8060,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } private _handleSessionRemoved(session: URI | string): void { - const rawId = AgentSession.id(session); + const rawId = session.toString(); this._onBackendSessionRemoved(rawId); const cached = this._removeCachedSession(rawId); if (cached) { @@ -8036,7 +8077,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (expected && cached && cached !== expected) { return undefined; } - this._metaByRawId.delete(rawId); + this._metadataBySession.delete(rawId); this._chatCatalogLoading.delete(rawId); const stateOwner = cached ?? expected; if (!stateOwner) { @@ -8045,6 +8086,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (cached) { this._sessionCache.delete(rawId); } + this._sessionKeysByResource.delete(stateOwner.resource.with({ fragment: '' })); this._runningSessionConfigs.delete(stateOwner.sessionId); this._runningSessionConfigResolveSeq.delete(stateOwner.sessionId); this._runningModelConfigurations.deleteAndDispose(stateOwner.sessionId); @@ -8060,7 +8102,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } private _handleTitleChanged(session: string, title: string): void { - const rawId = AgentSession.id(session); + const rawId = session; const cached = this._sessionCache.get(rawId); if (cached) { cached.title.set(title, undefined); @@ -8069,7 +8111,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } private _handleIsArchivedChanged(session: string, isArchived: boolean): void { - const rawId = AgentSession.id(session); + const rawId = session; const cached = this._sessionCache.get(rawId); if (cached) { cached.isArchived.set(this._resolveArchivedState(rawId, isArchived), undefined); @@ -8078,7 +8120,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } private _handleIsReadChanged(session: string, isRead: boolean): void { - const rawId = AgentSession.id(session); + const rawId = session; const cached = this._sessionCache.get(rawId); if (cached) { if (!cached.supportsChatReadState()) { @@ -8104,7 +8146,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // updates in `_ensureSessionStateSubscription` must not run nested in `tx`). let reopenStateSubscriptionFor: string | undefined; transaction((tx) => { - const rawId = AgentSession.id(session); + const rawId = session; const cached = this._sessionCache.get(rawId); if (!cached) { return; @@ -8183,10 +8225,10 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // adoptable-legacy marker reopens the passive session-state // subscription in `_ensureSessionStateSubscription`. Use `hasOwnProperty` // (like `activity` above) so an explicit clear to `undefined` applies. - const storedMeta = this._metaByRawId.get(rawId); + const storedMeta = this._metadataBySession.get(rawId); const wasAdoptable = readSessionEhcliAdoptable(storedMeta?._meta); if (storedMeta) { - this._metaByRawId.set(rawId, { ...storedMeta, _meta: changes._meta }); + this._metadataBySession.set(rawId, { ...storedMeta, _meta: changes._meta }); } if (cached.setMeta(changes._meta, tx)) { didChange = true; @@ -8210,7 +8252,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } private _handleConfigChanged(session: string, config: Record, replace: boolean): void { - const rawId = AgentSession.id(session); + const rawId = session; const cached = this._sessionCache.get(rawId); if (!cached) { return; @@ -8235,7 +8277,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } private _handleSessionMetaChanged(session: string, meta: Record | undefined): void { - const rawId = AgentSession.id(session); + const rawId = session; const cached = this._sessionCache.get(rawId); if (cached?.setMeta(meta)) { this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/codexContinuation.contribution.ts b/src/vs/sessions/contrib/providers/agentHost/browser/codexContinuation.contribution.ts new file mode 100644 index 00000000000000..08206128e165be --- /dev/null +++ b/src/vs/sessions/contrib/providers/agentHost/browser/codexContinuation.contribution.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { getWindow, runAtThisOrScheduleAtNextAnimationFrame } from '../../../../../base/browser/dom.js'; +import { Event } from '../../../../../base/common/event.js'; +import { Disposable, DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../../base/common/observable.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; +import { CODEX_CONTINUATION_DISABLE_LABEL, CODEX_CONTINUATION_LABEL, CODEX_CONTINUATION_MESSAGE, CodexContinuationGuide, ICodexContinuationNavigation } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/codexContinuationGuide.js'; +import { CodexContinuationPresenter } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/codexContinuationPresenter.js'; +import { findOnboardingTarget } from '../../../../../workbench/contrib/onboarding/browser/spotlight/onboardingTarget.js'; +import { ICodexContinuationService } from '../../../../../workbench/services/agentHost/browser/codexContinuationService.js'; +import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; +import { ISessionsListNoticeHost, registerSessionsListNotice, SessionsListNotice } from '../../../sessions/browser/views/sessionsListNotice.js'; + +class CodexContinuationNotice extends Disposable { + constructor( + host: ISessionsListNoticeHost, + @IInstantiationService instantiation: IInstantiationService, + @ICodexContinuationService nudge: ICodexContinuationService, + @ISessionsService sessions: ISessionsService, + @IChatWidgetService widgets: IChatWidgetService, + ) { + super(); + const guide = this._register(instantiation.createInstance(CodexContinuationGuide)); + const navigation: ICodexContinuationNavigation = { + getActiveWidget: () => { + const active = sessions.activeSession.get(); + return active && active.activeChat.get()?.resource.toString() === active.resource.toString() + ? widgets.getWidgetBySessionResource(active.resource) : undefined; + }, + revealSession: async (resource, token) => { + if (token.isCancellationRequested || !host.isVisible()) { return undefined; } + const reveal = host.revealSession(resource); + return { + onDidOpen: host.onDidOpenSession, + getElement: () => findOnboardingTarget(getWindow(host.container), reveal.targetId), + open: token => reveal.open(token), + focus: host.focusSessionsList, + dispose: () => reveal.dispose(), + }; + }, + }; + this._register(autorun(reader => nudge.setActiveSession(sessions.activeSession.read(reader)?.resource))); + this._register(instantiation.createInstance(CodexContinuationPresenter, { + surface: 'agentsWindow', + onDidChangePresentability: Event.map(host.onDidChangeVisibility, () => undefined), + isPresentable: () => host.isVisible() && host.container.isConnected && host.container.getClientRects().length > 0, + show: (candidate, visible, close, runAction) => { + const store = new DisposableStore(); + const notice = store.add(instantiation.createInstance(SessionsListNotice, { + description: CODEX_CONTINUATION_MESSAGE, label: CODEX_CONTINUATION_LABEL, disableLabel: CODEX_CONTINUATION_DISABLE_LABEL, + focusSessionsList: host.focusSessionsList, + dismiss: () => close('dismissed'), + disable: () => { void runAction(() => nudge.disable('agentsWindow')); }, + run: () => { void runAction(() => guide.run(candidate, 'agentsWindow', navigation)); }, + })); + host.container.appendChild(notice.domNode); + store.add(toDisposable(() => notice.domNode.remove())); + store.add(runAtThisOrScheduleAtNextAnimationFrame(notice.domNode.ownerDocument.defaultView!, async () => { + if (host.isVisible() && notice.domNode.getClientRects().length > 0) { + if (await visible() && !store.isDisposed) { host.announce(CODEX_CONTINUATION_MESSAGE); } + } + })); + return store; + }, + })); + } +} +registerSessionsListNotice((instantiation, host) => instantiation.createInstance(CodexContinuationNotice, host)); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts index dca4b0a3405c19..077be543cf2150 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts @@ -15,6 +15,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { type AgentHostUriMapper, LOCAL_AGENT_HOST_AUTHORITY, toAgentHostContentUri, toAgentHostUri } from '../../../../../platform/agentHost/common/agentHostUri.js'; import { AgentSession, type IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agent.js'; +import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { affectsAgentHostProviderPreference, IAgentConnection, IAgentHostService, shouldSurfaceLocalAgentHostProvider } from '../../../../../platform/agentHost/common/agentService.js'; import { workspacelessScratchDir } from '../../../../../platform/agentHost/common/workspacelessScratchDir.js'; import { findDevContainerSample, getDevContainerSampleUrl } from '../../../../../platform/agentHost/common/devContainerSamples.js'; @@ -110,8 +111,9 @@ export class LocalAgentHostSessionsProvider extends DevContainerAgentHostSession // An un-adopted legacy chat still carries the adoptable marker and must take // the migration probe; only a surfaced external / already-adopted session // short-circuits, since opening its twin is a plain (non-migrating) open. - const adoptable = rawId ? readSessionEhcliAdoptable(this._getSessionMetadataByRawId(rawId)) : false; - if (rawId && this._sessionCache.has(rawId) && !adoptable) { + const sessionKey = rawId ? AgentSession.uri('copilotcli', rawId).toString() : undefined; + const adoptable = sessionKey ? readSessionEhcliAdoptable(this._getSessionMetadataByKey(sessionKey)) : false; + if (sessionKey && this._sessionCache.has(sessionKey) && !adoptable) { return twin; // already surfaced and not an un-adopted legacy chat; no round-trip } // Startup restore reopens persisted slots against a cold host, where the @@ -150,6 +152,7 @@ export class LocalAgentHostSessionsProvider extends DevContainerAgentHostSession @IPathService pathService: IPathService, @ISessionsRecentWorkspacesService recentWorkspacesService: ISessionsRecentWorkspacesService, @IUriIdentityService uriIdentityService: IUriIdentityService, + @IAgentHostConnectionsService private readonly _connectionsService: IAgentHostConnectionsService, ) { super(chatSessionsService, chatService, chatWidgetService, languageModelsService, _configurationService, logService, gitHubService, instantiationService, sessionsService, activeClientService, storageService, dialogService, workspaceTrustManagementService, recentWorkspacesService, uriIdentityService); this.initializeDevContainerSupport(devContainerAgentHostService, sessionsProvidersService, workspaceTrustRequestService); @@ -157,6 +160,9 @@ export class LocalAgentHostSessionsProvider extends DevContainerAgentHostSession toHost: resource => resource, fromHost: resource => resource, resourceSchemeForProvider: provider => this.resourceSchemeForProvider(provider), + sessionResource: resource => this._connectionsService.findSessionResource(resource) + ?? (AgentSession.provider(resource) ? this._connectionsService.getSessionResource(resource) : undefined), + onDidChangeSessionResolution: this._connectionsService.onDidChangeSessionResolution, providerForResourceScheme: scheme => scheme.startsWith(LOCAL_RESOURCE_SCHEME_PREFIX) ? scheme.slice(LOCAL_RESOURCE_SCHEME_PREFIX.length) : undefined, })); this.automations = automations; @@ -184,6 +190,7 @@ export class LocalAgentHostSessionsProvider extends DevContainerAgentHostSession if (session.isQuickChat?.get() && (session.sessionType === 'copilotcli' || session.sessionType === 'claude')) { homes.push({ uri: workspacelessScratchDir(userHome, rawId), label }); } + if (session.sessionType === 'copilotcli') { homes.push({ uri: joinPath(sessionStateRoot, rawId), label }); for (const artifact of session.artifacts?.get() ?? []) { @@ -274,6 +281,10 @@ export class LocalAgentHostSessionsProvider extends DevContainerAgentHostSession return workspaceUri.scheme === Schemas.file || !!findDevContainerSample(workspaceUri); } + protected override registerBackendSession(backendSession: URI, provider: string): void { + this._connectionsService.registerSessionResource(backendSession, undefined, provider); + } + override getSessions(): ISession[] { const sessions = super.getSessions(); this.syncAutomationSessionMarkers(sessions); 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 3ef4cc6c752749..b441636f09f1e6 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 @@ -1738,6 +1738,7 @@ suite('AgentHostAutomationStore', () => { createdAt: timestamp, modifiedAt: timestamp, }); + const projected = store.getAutomation('local-agent-host:ahp-automation:/host-authored'); await store.updateAutomation('local-agent-host:ahp-automation:/host-authored', { enabled: false }); const update = connection.dispatched.at(-1)?.action; @@ -1763,6 +1764,41 @@ suite('AgentHostAutomationStore', () => { }); }); + test('standard run resources use advertised session identity rather than the mutable definition provider', async () => { + const connection = disposables.add(new TestAutomationConnection()); + const changed = disposables.add(new Emitter()); + const resources = new ResourceMap(); + const store = disposables.add(new AgentHostAutomationStore('local-agent-host', connection, { + toHost: resource => resource, fromHost: resource => resource, + resourceSchemeForProvider: provider => `agent-host-${provider}`, + sessionResource: resource => resources.get(resource), + onDidChangeSessionResolution: changed.event, + }, new NullLogService(), disposables.add(new InMemoryStorageService()), activeClientService)); + const timestamp = new Date().toISOString(); + const backend = URI.parse('ahp-session:/historical-run'); + const definition: AutomationEntry = { + resource: 'ahp-automation:/changed-provider', + definition: { + title: 'Changed provider', enabled: true, triggers: [], + message: { text: 'Run', origin: { kind: MessageKind.Automation } }, + session: { provider: 'claude' }, + }, + runs: [{ + resource: 'ahp-automation-run:/historical', automation: 'ahp-automation:/changed-provider', + origin: { kind: AutomationRunOriginKind.Manual }, primarySession: backend.toString(), sessionCount: 1, + lifecycle: { status: AutomationRunStatus.Completed, createdAt: timestamp, startedAt: timestamp, completedAt: timestamp }, + }], + operations: [], createdAt: timestamp, modifiedAt: timestamp, + }; + connection.setAutomation(definition); + const observed: (string | undefined)[] = []; + disposables.add(autorun(reader => observed.push(store.runs.read(reader)[0]?.sessionResource?.toString()))); + resources.set(backend, URI.parse('agent-host-codex:/historical-run')); + changed.fire(); + connection.setAutomation({ ...definition, definition: { ...definition.definition, session: { provider: 'copilotcli' } } }); + assert.deepStrictEqual(observed, [undefined, 'agent-host-codex:/historical-run', 'agent-host-codex:/historical-run']); + }); + test('uses per-automation operations as the client authority', async () => { const connection = new TestAutomationConnection(); disposables.add(connection); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostModePicker.fixture.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostModePicker.fixture.ts index 6fbdf8eb5ced89..19b5efce80ba21 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostModePicker.fixture.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostModePicker.fixture.ts @@ -27,7 +27,7 @@ import { IContextViewService } from '../../../../../../platform/contextview/brow import { ContextViewService } from '../../../../../../platform/contextview/browser/contextViewService.js'; import { ILayoutService } from '../../../../../../platform/layout/browser/layoutService.js'; import { AgentHostChatInputPicker } from '../../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.js'; -import { toAgentHostBackendSessionUri } from '../../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionUri.js'; +import { getLocalAgentHostSessionProvider } from '../../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionUri.js'; import { IAgentHostSessionWorkingDirectoryResolver } from '../../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionWorkingDirectoryResolver.js'; import { IAgentHostNewSessionFolderService } from '../../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostNewSessionFolderService.js'; import { IAgentHostUntitledProvisionalSessionService } from '../../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.js'; @@ -210,8 +210,13 @@ async function render(context: ComponentFixtureContext, mode: string, permission instantiationService.stub(IAgentHostConnectionsService, { ambientConnection: hostService, onDidChangeSessionResolution: Event.None, + resolveSessionResourceIdentity: sessionResource => { + const provider = getLocalAgentHostSessionProvider(sessionResource); + return provider ? { backendSession: sessionResource.with({ scheme: provider, fragment: '' }), connectionAuthority: AMBIENT_AGENT_HOST_AUTHORITY } : undefined; + }, resolveSessionResource: sessionResource => { - const backendSession = toAgentHostBackendSessionUri(sessionResource); + const provider = getLocalAgentHostSessionProvider(sessionResource); + const backendSession = provider ? sessionResource.with({ scheme: provider, fragment: '' }) : undefined; return backendSession ? { connection: hostService, backendSession, connectionAuthority: AMBIENT_AGENT_HOST_AUTHORITY } : 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 c68cc7bebb8547..525c2bafc7be7b 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 @@ -16,12 +16,14 @@ import { Emitter, Event } from '../../../../../../base/common/event.js'; import { DisposableMap, DisposableStore, ImmortalReference, toDisposable, type IReference } from '../../../../../../base/common/lifecycle.js'; import { autorun, constObservable, derived, ISettableObservable, observableFromEvent, observableValue, type IObservable } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; +import { ResourceMap } from '../../../../../../base/common/map.js'; import { extUriIgnorePathCase, isEqual } from '../../../../../../base/common/resources.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 { AgentSession, CODEX_AGENT_PROVIDER_ID, type IAgentCreateChatRequestOptions, type IAgentCreateSessionConfig, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata } from '../../../../../../platform/agentHost/common/agent.js'; import { agentSdkSetupStatusKey } from '../../../../../../platform/agentHost/common/agentSdkSetup.js'; +import { IAgentHostConnectionsService } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { AgentHostCodexAgentEnabledSettingId, IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; import { getAgentHostExtensionInitializeResultMeta } from '../../../../../../platform/agentHost/common/agentHostExtensionProtocol.js'; import { AgentHostAutonomousAutomationsCapabilityMetaKey } from '../../../../../../platform/agentHost/common/meta/agentHostAutomationsMeta.js'; @@ -61,7 +63,7 @@ import { ChatModeKind } from '../../../../../../workbench/contrib/chat/common/co import { ILanguageModelsService, type ILanguageModelChatMetadata } from '../../../../../../workbench/contrib/chat/common/languageModels.js'; import type { IChatModel, IChatModelInputState, IInputModel } from '../../../../../../workbench/contrib/chat/common/model/chatModel.js'; import { ISessionChangeEvent, ISessionsProvider, type ISessionsProviderCreateSessionOptions } from '../../../../../services/sessions/common/sessionsProvider.js'; -import { BRANCH_CHANGES_CHANGESET_ID, ChatInteractivity, ChatModelSource, ChatOriginKind, getChatCapabilities, getGitHubPullRequestRefs, IChat, ISession, SESSION_CHANGES_CHANGESET_ID, SessionStatus, TURN_CHANGES_CHANGESET_ID } from '../../../../../services/sessions/common/session.js'; +import { BRANCH_CHANGES_CHANGESET_ID, ChatInteractivity, ChatModelSource, ChatOriginKind, getChatCapabilities, getGitHubPullRequestRefs, IChat, ISession, ISessionWorkspace, SESSION_CHANGES_CHANGESET_ID, SessionStatus, TURN_CHANGES_CHANGESET_ID, toSessionId } from '../../../../../services/sessions/common/session.js'; import { getSessionGitHubReferences } from '../../../../github/common/sessionGitHubReferences.js'; import { IActiveSession, ISessionsManagementService, WorkspaceNotTrustedError } from '../../../../../services/sessions/common/sessionsManagement.js'; import { SessionsManagementService } from '../../../../../services/sessions/browser/sessionsManagementService.js'; @@ -139,7 +141,7 @@ class MockAgentHostService extends mock() { protocolVersion: PROTOCOL_VERSION, serverSeq: 0, snapshots: [], - _meta: { 'vscode.detachedWorktrees': true, [AgentHostAutonomousAutomationsCapabilityMetaKey]: true }, + _meta: { 'vscode.agentHost': true, 'vscode.detachedWorktrees': true, [AgentHostAutonomousAutomationsCapabilityMetaKey]: true }, }); override readonly clientId = 'test-local-client'; private readonly _sessions = new Map(); @@ -330,10 +332,12 @@ class MockAgentHostService extends mock() { override dispatch(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction): void { this.dispatchedActions.push({ channel, action, clientId: this.clientId, clientSeq: this._nextSeq++ }); if (action.type === ActionType.ChatIsArchivedChanged || action.type === ActionType.ChatIsReadChanged) { - const session = URI.parse(parseRequiredSessionUriFromChatUri(channel)); + const session = [...this._sessions.values()] + .find(metadata => metadata.chats?.some(chat => chat.chat.toString() === channel))?.session + ?? URI.parse(parseRequiredSessionUriFromChatUri(channel)); const existing = this._sessionStateValues.get(session.toString()) as SessionState | undefined; if (existing) { - this.setSessionState(AgentSession.id(session), AgentSession.provider(session)!, { + this.setSessionStateResource(session, { ...existing, chats: existing.chats.map(summary => summary.resource === channel ? { @@ -622,6 +626,21 @@ function createProvider(disposables: DisposableStore, agentHostService: MockAgen const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(IAgentHostService, agentHostService); + instantiationService.stub(IAgentHostConnectionsService, new class extends mock() { + private readonly _resources = new ResourceMap(); + private readonly _changed = disposables.add(new Emitter()); + override readonly onDidChangeSessionResolution = this._changed.event; + override findSessionResource(backend: URI): URI | undefined { return this._resources.get(backend); } + override registerSessionResource(backend: URI, authority = 'local', provider = backend.scheme): URI { + const resource = backend.with({ scheme: authority === 'local' ? `agent-host-${provider}` : `remote-${authority}-${provider}` }); + if (!this._resources.has(backend)) { + this._resources.set(backend, resource); + this._changed.fire(); + } + return resource; + } + override getSessionResource(backend: URI): URI | undefined { return this._resources.get(backend); } + }()); const configurationService = options?.configurationService ?? new TestConfigurationService(); instantiationService.stub(IConfigurationService, configurationService); instantiationService.stub(IWorkspaceTrustManagementService, new class extends mock() { @@ -760,9 +779,9 @@ async function waitForSessionConfig(provider: LocalAgentHostSessionsProvider, se }); } -function fireSessionAdded(agentHost: MockAgentHostService, rawId: string, opts?: { provider?: string; title?: string; status?: ProtocolSessionStatus; project?: { uri: string; displayName: string }; workingDirectory?: string; workingDirectories?: readonly string[]; changes?: ChangesSummary; workspaceless?: boolean; createdAt?: string; modifiedAt?: string }): void { +function fireSessionAdded(agentHost: MockAgentHostService, rawId: string, opts?: { resource?: URI; provider?: string; title?: string; status?: ProtocolSessionStatus; project?: { uri: string; displayName: string }; workingDirectory?: string; workingDirectories?: readonly string[]; changes?: ChangesSummary; workspaceless?: boolean; createdAt?: string; modifiedAt?: string }): void { const provider = opts?.provider ?? 'copilotcli'; - const sessionUri = AgentSession.uri(provider, rawId); + const sessionUri = opts?.resource ?? AgentSession.uri(provider, rawId); agentHost.fireNotification({ channel: 'ahp-root://', type: NotificationType.SessionAdded, @@ -1538,7 +1557,7 @@ suite('LocalAgentHostSessionsProvider', () => { const provider = createProvider(disposables, agentHost); await timeout(0); fireSessionAdded(agentHost, 'to-remove', { title: 'Removed' }); - const metadata = Reflect.get(provider, '_metaByRawId') as Map; + const metadata = Reflect.get(provider, '_metadataBySession') as Map; const changes: ISessionChangeEvent[] = []; disposables.add(provider.onDidChangeSessions(e => changes.push(e))); @@ -1549,7 +1568,7 @@ suite('LocalAgentHostSessionsProvider', () => { assert.deepStrictEqual({ removed: changes[0]?.removed.length, session: provider.getSessions().find(s => s.title.get() === 'Removed'), - metadata: metadata.get('to-remove'), + metadata: metadata.get('copilotcli:/to-remove'), }, { removed: 1, session: undefined, @@ -1864,6 +1883,39 @@ suite('LocalAgentHostSessionsProvider', () => { }); })); + test('rejected backend registration does not allocate orphaned adapters across refresh retries', async () => { + agentHost.addSession(createSession('registration-retry')); + let rejectRegistration = true; + let attempts = 0; + class RejectedRegistrationProvider extends LocalAgentHostSessionsProvider { + protected override registerBackendSession(backendSession: URI, provider: string): void { + attempts++; + if (rejectRegistration) { + throw new Error('Conflicting backend identities'); + } + super.registerBackendSession(backendSession, provider); + } + + async refreshForTest(): Promise { + await this._refreshSessions(); + } + } + const provider = createProvider(disposables, agentHost, undefined, { providerCtor: RejectedRegistrationProvider }); + assert.ok(provider instanceof RejectedRegistrationProvider); + await timeout(0); + for (let retry = 0; retry < 4; retry++) { + await provider.refreshForTest(); + } + const rejectedSessions = provider.getSessions().length; + rejectRegistration = false; + await provider.refreshForTest(); + assert.deepStrictEqual({ + attempts, + rejectedSessions, + recovered: provider.getSessions().map(session => session.resource.toString()), + }, { attempts: 6, rejectedSessions: 0, recovered: ['agent-host-copilotcli:/registration-retry'] }); + }); + test('a session whose agent reports nothing survives the refresh', () => runWithFakedTimers({ useFakeTimers: true }, async () => { // The host aggregates one listing across all of its agents, and an // agent that cannot enumerate yet (SDK not downloaded) contributes an @@ -1984,6 +2036,158 @@ suite('LocalAgentHostSessionsProvider', () => { // ---- Startup session cache (persistence) ------- + test('opaque advertised chats work from cached metadata without optional host metadata or live subscriptions', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const hostResource = URI.parse('ahp-session://tenant/opaque/session'); + const defaultChat = URI.parse('conversation://tenant/main'); + const peerChat = URI.parse('conversation://tenant/peer'); + const metadata: IAgentSessionMetadata = { + session: hostResource, provider: 'copilotcli', startTime: 1000, modifiedTime: 2000, + status: ProtocolSessionStatus.Idle | ProtocolSessionStatus.IsRead, + chats: [{ chat: defaultChat, kind: 'default', isRead: true }, { chat: peerChat, kind: 'peer', isRead: false }], + }; + const storageService = disposables.add(new InMemoryStorageService()); + await persistCachedSessions(disposables, storageService, [metadata]); + const nextHost = new MockAgentHostService(); + disposables.add(toDisposable(() => nextHost.dispose())); + nextHost.setAuthenticationPending(true); + const provider = createProvider(disposables, nextHost, undefined, { storageService }); + const session = provider.getSessions()[0]; + assert.ok(session instanceof AgentHostSessionAdapter); + assert.deepStrictEqual({ + backend: session.backendUri.toString(), + resource: session.resource.toString(), + chats: session.chats.get().map(chat => [chat.resource.fragment, provider.getBackendChatResource(chat.resource)?.toString()]), + sessionIsRead: session.isRead.get(), + chatReadState: session.chats.get().map(chat => chat.isRead.get()), + }, { + backend: hostResource.toString(), + resource: hostResource.with({ scheme: 'agent-host-copilotcli' }).toString(), + chats: [['', defaultChat.toString()], [peerChat.toString(), peerChat.toString()]], + sessionIsRead: false, + chatReadState: [true, false], + }); + })); + + for (const withPeer of [false, true]) { + test(`cache does not invent an unadvertised default chat (${withPeer})`, () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const backend = URI.parse('ahp-session://tenant/opaque/session'); + const peer = URI.parse('conversation://tenant/peer'); + const storageService = disposables.add(new InMemoryStorageService()); + await persistCachedSessions(disposables, storageService, [{ + session: backend, + provider: 'copilotcli', + startTime: 1000, + modifiedTime: 2000, + chats: withPeer ? [{ chat: peer, kind: 'peer' }] : [], + }]); + const nextHost = new MockAgentHostService(); + disposables.add(toDisposable(() => nextHost.dispose())); + nextHost.setAuthenticationPending(true); + const provider = createProvider(disposables, nextHost, undefined, { storageService }); + const session = provider.getSessions()[0]; + const cached = storageService.getObject('localAgentHost.cachedSessions.v4', StorageScope.APPLICATION) as Array<{ chats: Array<{ chat: string }> }> | undefined; + + assert.deepStrictEqual({ + cached: cached?.[0].chats.map(chat => chat.chat), + defaultChat: provider.getBackendChatResource(session.resource)?.toString(), + peerChat: provider.getBackendChatResource(session.resource.with({ fragment: peer.toString() }))?.toString(), + }, { + cached: withPeer ? [peer.toString()] : [], + defaultChat: undefined, + peerChat: withPeer ? peer.toString() : undefined, + }); + })); + } + + for (const resource of ['copilotcli:/chat-read-routing', 'ahp-session:/chat-read-routing', 'ahp-session://tenant/opaque/session']) { + test(`chat read state routes exact advertised resources for ${resource}`, async () => { + const backend = URI.parse(resource); + const defaultChat = backend.authority ? URI.parse('conversation://tenant/main') : URI.parse(buildDefaultChatUri(backend)); + const peerChat = backend.authority ? URI.parse('conversation://tenant/peer') : URI.parse(buildChatUri(backend, 'peer')); + agentHost.addSession({ + ...createSession('chat-read-routing', { + status: ProtocolSessionStatus.Idle | ProtocolSessionStatus.IsRead, + chats: [{ chat: defaultChat, kind: 'default', isRead: true }, { chat: peerChat, kind: 'peer', isRead: true }], + }), + session: backend, + provider: 'copilotcli', + }); + const provider = createProvider(disposables, agentHost); + await timeout(0); + const session = provider.getSessions()[0]; + provider.getSessionConfig(session.sessionId); + agentHost.setSessionStateResource(backend, { + provider: 'copilotcli', + title: 'Read State', + status: ProtocolSessionStatus.Idle, + lifecycle: SessionLifecycle.Ready, + activeClients: [], + defaultChat: defaultChat.toString(), + chats: [ + { resource: defaultChat.toString(), title: 'Default', status: ProtocolSessionStatus.Idle | ProtocolSessionStatus.IsRead, modifiedAt: new Date(0).toISOString() }, + { resource: peerChat.toString(), title: 'Peer', status: ProtocolSessionStatus.Idle, modifiedAt: new Date(0).toISOString() }, + ], + }); + const before = { session: session.isRead.get(), chats: session.chats.get().map(chat => chat.isRead.get()) }; + + await provider.setSessionReadState(session.sessionId, true); + + assert.deepStrictEqual({ + before, + after: { session: session.isRead.get(), chats: session.chats.get().map(chat => chat.isRead.get()) }, + actions: agentHost.dispatchedActions + .filter(dispatch => dispatch.action.type === ActionType.ChatIsReadChanged || dispatch.action.type === ActionType.SessionIsReadChanged) + .map(dispatch => ({ channel: dispatch.channel, type: dispatch.action.type })), + }, { + before: { session: false, chats: [true, false] }, + after: { session: true, chats: [true, true] }, + actions: [ + { channel: peerChat.toString(), type: ActionType.ChatIsReadChanged }, + { channel: backend.toString(), type: ActionType.SessionIsReadChanged }, + ], + }); + }); + } + + test('mixed resources and cross-provider raw IDs keep frontend identity after cache reload', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const storageService = disposables.add(new InMemoryStorageService()); + const metas = [ + { ...createSession('legacy'), provider: 'copilotcli' }, + { ...createSession('new'), session: URI.parse('ahp-session:/new'), provider: 'copilotcli' }, + { ...createSession('shared'), session: URI.parse('codex:/shared'), provider: 'codex' }, + { ...createSession('shared'), session: URI.parse('claude:/shared'), provider: 'claude' }, + ]; + const previousHost = new MockAgentHostService(); + disposables.add(toDisposable(() => previousHost.dispose())); + previousHost.listSessions = async () => metas; + createProvider(disposables, previousHost, undefined, { storageService }); + await timeout(0); + await storageService.flush(); + const nextHost = new MockAgentHostService(); + disposables.add(toDisposable(() => nextHost.dispose())); + nextHost.setAuthenticationPending(true); + const provider = createProvider(disposables, nextHost, undefined, { storageService, configurationService: new TestConfigurationService({ 'chat.agentHost.codexAgent.enabled': true }) }); + const rows = provider.getSessions().map(session => { + assert.ok(session instanceof AgentHostSessionAdapter); + return [session.resource.toString(), session.backendUri.toString(), session.agentProvider, session.sessionId]; + }).sort(); + const configurations = provider.getSessions().map(session => provider.getAutomationModelConfiguration(session.sessionId)); + assert.deepStrictEqual({ + rows, + modelConfigurations: configurations.every(configuration => configuration !== undefined), + distinctConfigurations: new Set(configurations).size, + stableConfigurations: provider.getSessions().every((session, index) => provider.getAutomationModelConfiguration(session.sessionId) === configurations[index]), + }, { + rows: metas.map(meta => { + const resource = URI.from({ scheme: `agent-host-${meta.provider}`, path: meta.session.path }); + return [resource.toString(), meta.session.toString(), meta.provider, toSessionId(provider.id, resource)]; + }).sort(), + modelConfigurations: true, + distinctConfigurations: metas.length, + stableConfigurations: true, + }); + })); + test('hydrates persisted sessions on startup before the live list is available', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const storageService = disposables.add(new InMemoryStorageService()); await persistCachedSessions(disposables, storageService, [createSession('cached-1', { summary: 'Cached One' })]); @@ -3889,7 +4093,7 @@ suite('LocalAgentHostSessionsProvider', () => { automation: 'ahp-automation:/automation', origin: { kind: AutomationRunOriginKind.Manual }, lifecycle: { status: AutomationRunStatus.Running, createdAt: '2026-01-01T00:00:00Z', startedAt: '2026-01-01T00:00:00Z' }, - primarySession: AgentSession.uri('copilotcli', 'automation-1').toString(), + primarySession: createSession('automation-1').session.toString(), sessionCount: 1, }], }], @@ -4200,6 +4404,57 @@ suite('LocalAgentHostSessionsProvider', () => { }); })); + test('workspace change project survives subsequent state updates with an unchanged old project', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const previous = URI.file('/home/user/previous'); + const next = URI.file('/home/user/next'); + const previousProject = { uri: previous.toString(), displayName: 'previous' }; + agentHost.addSession(createSession('workspace-change', { + project: { uri: previous, displayName: previousProject.displayName }, + workingDirectory: previous, + })); + const provider = createProvider(disposables, agentHost); + provider.getSessions(); + await timeout(0); + const session = provider.getSessions()[0]; + provider.getSessionConfig(session.sessionId); + const defaultChat = buildDefaultChatUri(AgentSession.uri('copilotcli', 'workspace-change')); + const state: SessionState = { + provider: 'copilotcli', title: 'Chat', status: ProtocolSessionStatus.Idle, + lifecycle: SessionLifecycle.Ready, activeClients: [], defaultChat, + project: previousProject, workingDirectories: [previous.toString()], + chats: [{ resource: defaultChat, title: 'Chat', status: ProtocolSessionStatus.Idle, modifiedAt: new Date(0).toISOString() }], + }; + agentHost.setSessionState('workspace-change', 'copilotcli', state); + agentHost.setSessionState('workspace-change', 'copilotcli', { + ...state, + workingDirectories: [next.toString()], + chats: [{ ...state.chats[0], workingDirectories: [next.toString()] }], + }); + fireSessionSummaryChanged(agentHost, 'workspace-change', { + project: { uri: next.toString(), displayName: 'next' }, + workingDirectories: [next.toString()], + }); + await timeout(0); + agentHost.setSessionState('workspace-change', 'copilotcli', { + ...state, + workingDirectories: [next.toString()], + chats: [{ ...state.chats[0], workingDirectories: [next.toString()] }], + activity: 'Continuing the task', + }); + + assert.deepStrictEqual({ + sessionLabel: session.workspace.get()?.label, + sessionRoot: session.workspace.get()?.folders[0]?.root.toString(), + chatRoot: session.chats.get()[0].workspace.get()?.folders[0]?.root.toString(), + chatDirectory: session.chats.get()[0].workspace.get()?.folders[0]?.workingDirectory.toString(), + }, { + sessionLabel: 'next', + sessionRoot: next.toString(), + chatRoot: next.toString(), + chatDirectory: next.toString(), + }); + })); + test('isolated conversion groups by repository while running in the worktree', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const scratchDirectory = URI.file('/tmp/copilot-scratch/quick-isolated'); const repository = URI.file('/home/user/project'); @@ -7338,6 +7593,46 @@ suite('LocalAgentHostSessionsProvider', () => { ); }); + for (const agentProvider of ['copilotcli', 'codex', 'claude']) { + for (const supported of [false, true]) { + test(`${agentProvider} eager allocation registers its exact address before catalog publication (${supported})`, async () => { + agentHost.initializeResult.set({ + ...agentHost.initializeResult.get(), + _meta: { 'vscode.agentHost': true, 'vscode.ahpSessionUris': supported }, + }, undefined); + agentHost.setAgents([{ provider: agentProvider, displayName: agentProvider, description: '', models: [] }]); + const registered: { backend: string; provider: string; subscribed: boolean }[] = []; + class RecordingProvider extends LocalAgentHostSessionsProvider { + protected override registerBackendSession(backendSession: URI, provider: string): void { + registered.push({ + backend: backendSession.toString(), + provider, + subscribed: agentHost.sessionSubscribeCounts.has(backendSession.toString()), + }); + super.registerBackendSession(backendSession, provider); + } + } + const provider = createProvider(disposables, agentHost, [{ + type: `agent-host-${agentProvider}`, name: agentProvider, displayName: agentProvider, description: 'test', icon: undefined, + }], { providerCtor: RecordingProvider, configurationService: new TestConfigurationService({ [AgentHostCodexAgentEnabledSettingId]: true }) }); + const draft = provider.createNewSession(URI.file('/home/user/project'), agentProvider); + await timeout(0); + const backend = `${supported ? 'ahp-session' : agentProvider}:${draft.resource.path}`; + assert.deepStrictEqual({ + created: agentHost.createdSessionUris.map(resource => resource.toString()), + registered, + published: provider.getSessions().length, + frontend: draft.resource.scheme, + }, { + created: [backend], + registered: [{ backend, provider: agentProvider, subscribed: false }], + published: 0, + frontend: `agent-host-${agentProvider}`, + }); + }); + } + } + test('createNewSession does not eagerly create the backend session in an untrusted folder', async () => { const provider = createProvider(disposables, agentHost, undefined, { workspaceTrusted: false }); const workspaceUri = URI.parse('file:///home/user/untrusted-project'); @@ -7487,12 +7782,12 @@ suite('LocalAgentHostSessionsProvider', () => { agentHost.setSessionState('del-sess', 'copilotcli', state); provider.getSessionConfig(target.sessionId); - const metadata = Reflect.get(provider, '_metaByRawId') as Map; + const metadata = Reflect.get(provider, '_metadataBySession') as Map; const lastStates = Reflect.get(provider, '_lastSessionStates') as Map; const subscriptions = Reflect.get(provider, '_sessionStateSubscriptions') as DisposableMap; const idleTimers = Reflect.get(provider, '_sessionStateIdleTimers') as DisposableMap; assert.deepStrictEqual({ - metadata: metadata.has('del-sess'), + metadata: metadata.has('copilotcli:/del-sess'), state: lastStates.has(target.sessionId), subscription: subscriptions.has(target.sessionId), timer: idleTimers.has(target.sessionId), @@ -7511,7 +7806,7 @@ suite('LocalAgentHostSessionsProvider', () => { id: AgentSession.id(uri), })), session: provider.getSessions().find(s => s.title.get() === 'To Delete'), - metadata: metadata.get('del-sess'), + metadata: metadata.get('copilotcli:/del-sess'), state: lastStates.get(target.sessionId), subscription: subscriptions.has(target.sessionId), timer: idleTimers.has(target.sessionId), @@ -7679,12 +7974,13 @@ suite('LocalAgentHostSessionsProvider', () => { }; } - function setupMultiChatSession(provider: ReturnType, rawId: string, workingDirectories?: readonly URI[]): ISession { + function setupMultiChatSession(provider: ReturnType, rawId: string, workingDirectories?: readonly URI[], backendSession?: URI): ISession { // Registered with the host as well as announced: `getSessions` starts a refresh, and an // authoritative empty list would evict the adapter the notification just created — // leaving later writes landing on an instance nothing reads. - agentHost.addSession(createSession(rawId, { summary: 'Session', workingDirectories })); - fireSessionAdded(agentHost, rawId, { title: 'Session', workingDirectories: workingDirectories?.map(uri => uri.toString()) }); + const metadata = createSession(rawId, { summary: 'Session', workingDirectories }); + agentHost.addSession(backendSession ? { ...metadata, session: backendSession, provider: 'copilotcli' } : metadata); + fireSessionAdded(agentHost, rawId, { resource: backendSession, title: 'Session', workingDirectories: workingDirectories?.map(uri => uri.toString()) }); const session = provider.getSessions().find(s => AgentSession.id(s.resource.toString()) === rawId); assert.ok(session); // Force a session-state subscription so pushed states reach the adapter. @@ -8515,6 +8811,48 @@ suite('LocalAgentHostSessionsProvider', () => { }); }); + for (const isolateMain of [false, true]) { + test(`isolating the ${isolateMain ? 'main' : 'peer'} chat updates its workspace and branch without moving a same-folder chat`, () => { + const provider = createProvider(disposables, agentHost); + const original = URI.file('/work/repo').toString(); + const worktree = URI.file('/work/repo.worktrees/isolated').toString(); + const id = 'chat-isolation'; + const session = setupMultiChatSession(provider, id, [URI.parse(original)]); + const sessionUri = AgentSession.uri('copilotcli', id).toString(); + const main = buildDefaultChatUri(sessionUri); + const peer = buildChatUri(sessionUri, 'peer-1'); + const publish = (isolated: boolean) => { + const meta = withSessionGitState(withWorkingDirectoryScopeId({ + gitData: { isolated: { branchName: 'agents/isolated', baseBranchName: 'main' } }, + }, [worktree], 'isolated'), { branchName: 'main' }); + agentHost.setSessionState(id, 'copilotcli', makeState([ + makeChatSummary(main, '', ProtocolSessionStatus.Idle, [isolated && isolateMain ? worktree : original]), + makeChatSummary(peer, 'Peer', ProtocolSessionStatus.Idle, [isolated && !isolateMain ? worktree : original]), + ], { defaultChat: main, meta, workingDirectories: isolated ? [original, worktree] : [original] })); + }; + publish(false); + const chats = session.chats.get(); + const folders = (chat: IChat) => chat.workspace.get()?.folders.map(folder => ({ + directory: folder.workingDirectory.toString(), branch: folder.gitRepository?.branchName, + })); + const before = chats.map(folders); + publish(true); + assert.deepStrictEqual({ + before, + after: chats.map(folders), + sameChats: session.chats.get().every((chat, index) => chat === chats[index]), + aggregate: session.workspace.get()?.folders.map(folder => folder.workingDirectory.toString()), + }, { + before: [[{ directory: original, branch: 'main' }], [{ directory: original, branch: 'main' }]], + after: [isolateMain, !isolateMain].map(isolated => [{ + directory: isolated ? worktree : original, branch: isolated ? 'agents/isolated' : 'main', + }]), + sameChats: true, + aggregate: [original, worktree], + }); + }); + } + test('main chat keeps the session workspace identity for the session folder', () => { const provider = createProvider(disposables, agentHost); const primaryDirectory = URI.file('/workspace-primary'); @@ -8528,6 +8866,91 @@ suite('LocalAgentHostSessionsProvider', () => { assert.strictEqual(session.mainChat.get().workspace.get(), session.workspace.get()); }); + test('single-chat isolation replaces the workspace but keeps the repository project identity', () => { + const provider = createProvider(disposables, agentHost); + const repository = URI.file('/work/repo'); + const worktree = URI.file('/work/repo.worktrees/isolated'); + const id = 'single-chat-isolation'; + const session = setupMultiChatSession(provider, id, [repository]); + const main = buildDefaultChatUri(AgentSession.uri('copilotcli', id)); + agentHost.setSessionState(id, 'copilotcli', makeState([ + makeChatSummary(main, '', ProtocolSessionStatus.Idle, [repository.toString()]), + ], { defaultChat: main, workingDirectories: [repository.toString()] })); + const chat = session.mainChat.get(); + const meta = withSessionGitState(undefined, { branchName: 'agents/isolated', baseBranchName: 'main' }); + fireSessionSummaryChanged(agentHost, id, { + project: { uri: repository.toString(), displayName: 'repo' }, + workingDirectories: [worktree.toString()], _meta: meta, + }); + agentHost.setSessionState(id, 'copilotcli', makeState([ + makeChatSummary(main, '', ProtocolSessionStatus.Idle, [worktree.toString()]), + ], { defaultChat: main, meta, workingDirectories: [worktree.toString()], configValues: { isolation: 'worktree' } })); + const describe = (workspace: ISessionWorkspace | undefined) => ({ + project: workspace?.uri.toString(), + folders: workspace?.folders.map(folder => ({ + root: folder.root.toString(), directory: folder.workingDirectory.toString(), + worktree: folder.gitRepository?.workTreeUri?.toString(), branch: folder.gitRepository?.branchName, + })), + }); + const expected = { + project: repository.toString(), + folders: [{ root: repository.toString(), directory: worktree.toString(), worktree: worktree.toString(), branch: 'agents/isolated' }], + }; + assert.deepStrictEqual({ + session: describe(session.workspace.get()), + chat: describe(chat.workspace.get()), + sameChat: chat === session.mainChat.get(), + }, { session: expected, chat: expected, sameChat: true }); + }); + + for (const scheme of ['copilotcli', 'ahp-session']) { + test(`authoritative ${scheme} isolation config clears worktree pending after moving back to a folder`, () => { + const provider = createProvider(disposables, agentHost); + const repository = URI.file('/work/repo'); + const destination = URI.file('/work/destination'); + const worktree = URI.file('/work/repo.worktrees/isolated'); + const id = 'isolation-pending-roundtrip'; + const backendSession = AgentSession.uri(scheme, id); + const session = setupMultiChatSession(provider, id, [repository], backendSession); + const main = buildDefaultChatUri(backendSession); + const publish = (isolation: string, directory: URI, project: URI) => { + const state = makeState([ + makeChatSummary(main, '', ProtocolSessionStatus.Idle, [directory.toString()]), + ], { defaultChat: main, workingDirectories: [directory.toString()] }); + agentHost.setSessionStateResource(backendSession, { + ...state, + project: { uri: project.toString(), displayName: 'repo' }, + config: { + schema: { type: 'object', properties: { isolation: { type: 'string', title: 'Isolation', enum: ['folder', 'worktree'] } } }, + values: { isolation }, + }, + }); + }; + const snapshots: { pending: boolean | undefined; directory: string | undefined }[] = []; + const snapshot = () => snapshots.push({ + pending: session.worktreePending?.get(), + directory: session.mainChat.get().workspace.get()?.folders[0].workingDirectory.toString(), + }); + publish('worktree', repository, repository); + snapshot(); + publish('worktree', worktree, repository); + snapshot(); + publish('folder', destination, destination); + snapshot(); + publish('worktree', destination, destination); + snapshot(); + publish('folder', destination, destination); + snapshot(); + assert.deepStrictEqual(snapshots, [ + { pending: true, directory: repository.toString() }, + { pending: false, directory: worktree.toString() }, + { pending: false, directory: destination.toString() }, + { pending: true, directory: destination.toString() }, + { pending: false, directory: destination.toString() }, + ]); + }); + } + test('Agent Merge observable updates when a peer chat folder hydrates', () => { const provider = createProvider(disposables, agentHost); const primaryDirectory = URI.file('/workspace-primary'); @@ -8841,71 +9264,131 @@ suite('LocalAgentHostSessionsProvider', () => { }); }); - test('legacy hosts preserve client read state and use the session read action', async () => { - agentHost.initializeResult.set({ ...agentHost.initializeResult.get(), protocolVersion: '0.9.0' }, undefined); - const sessionUri = AgentSession.uri('copilotcli', 'legacy-chat-read').toString(); - const defaultChat = buildDefaultChatUri(sessionUri); - const peerChat = buildChatUri(sessionUri, 'peer-1'); - const hiddenChat = buildChatUri(sessionUri, 'hidden'); - agentHost.addSession(createSession('legacy-chat-read', { - status: ProtocolSessionStatus.Idle | ProtocolSessionStatus.IsRead, - chats: [ - { chat: URI.parse(defaultChat), kind: 'default', summary: '' }, - { chat: URI.parse(peerChat), kind: 'peer', summary: 'Peer' }, - { chat: URI.parse(hiddenChat), kind: 'peer', summary: 'Hidden', interactivity: ProtocolChatInteractivity.Hidden }, - ], - })); - const provider = createProvider(disposables, agentHost); - provider.getSessions(); - await timeout(0); - const session = provider.getSessions().find(session => AgentSession.id(session.resource) === 'legacy-chat-read'); - assert.ok(session); - const initial = { - session: session.isRead.get(), - chats: session.chats.get().map(chat => chat.isRead.get()), - }; + for (const resource of ['copilotcli:/legacy-chat-read', 'ahp-session:/legacy-chat-read', 'session-store://tenant/legacy-chat-read?revision=2']) { + test(`legacy read-state support preserves advertised identity ${resource}`, async () => { + agentHost.initializeResult.set({ ...agentHost.initializeResult.get(), protocolVersion: '0.9.0' }, undefined); + const backendSession = URI.parse(resource); + const sessionUri = backendSession.toString(); + const defaultChat = backendSession.authority ? 'conversation://tenant/main' : buildDefaultChatUri(sessionUri); + const peerChat = backendSession.authority ? 'conversation://tenant/peer' : buildChatUri(sessionUri, 'peer-1'); + const hiddenChat = backendSession.authority ? 'conversation://tenant/hidden' : buildChatUri(sessionUri, 'hidden'); + agentHost.addSession({ + ...createSession('legacy-chat-read', { + status: ProtocolSessionStatus.Idle | ProtocolSessionStatus.IsRead, + chats: [ + { chat: URI.parse(defaultChat), kind: 'default', summary: '' }, + { chat: URI.parse(peerChat), kind: 'peer', summary: 'Peer' }, + { chat: URI.parse(hiddenChat), kind: 'peer', summary: 'Hidden', interactivity: ProtocolChatInteractivity.Hidden }, + ], + }), session: backendSession, provider: 'copilotcli' + }); + const provider = createProvider(disposables, agentHost); + provider.getSessions(); + await timeout(0); + const session = provider.getSessions().find(session => AgentSession.id(session.resource) === 'legacy-chat-read'); + assert.ok(session); + const initial = { + session: session.isRead.get(), + chats: session.chats.get().map(chat => chat.isRead.get()), + }; - const accepted = await provider.setChatReadState(session.sessionId, session.chats.get()[2].resource, true); - const retried = await provider.setChatReadState(session.sessionId, session.chats.get()[2].resource, true); - fireSessionSummaryChanged(agentHost, 'legacy-chat-read', { - chats: [ - { resource: defaultChat, title: '' }, - { resource: peerChat, title: 'Peer' }, - { resource: hiddenChat, title: 'Hidden', interactivity: ProtocolChatInteractivity.Hidden }, - ], - defaultChat, + const accepted = await provider.setChatReadState(session.sessionId, session.chats.get()[2].resource, true); + const retried = await provider.setChatReadState(session.sessionId, session.chats.get()[2].resource, true); + agentHost.fireNotification({ + channel: 'ahp-root://', type: NotificationType.SessionSummaryChanged, session: sessionUri, + changes: { + chats: [ + { resource: defaultChat, title: '' }, + { resource: peerChat, title: 'Peer' }, + { resource: hiddenChat, title: 'Hidden', interactivity: ProtocolChatInteractivity.Hidden }, + ], + defaultChat, + }, + }); + + assert.deepStrictEqual({ + initial, + accepted, + retried, + afterRefresh: { + session: session.isRead.get(), + chats: session.chats.get().map(chat => chat.isRead.get()), + }, + actions: agentHost.dispatchedActions.map(dispatch => ({ + channel: dispatch.channel, + type: dispatch.action.type, + isRead: dispatch.action.type === ActionType.SessionIsReadChanged ? dispatch.action.isRead : undefined, + })), + }, { + initial: { session: false, chats: [false, false, false] }, + accepted: true, + retried: true, + afterRefresh: { session: true, chats: [true, true, true] }, + actions: [ + { + channel: sessionUri, + type: ActionType.SessionIsReadChanged, + isRead: true, + }, + { + channel: sessionUri, + type: ActionType.SessionIsReadChanged, + isRead: true, + }, + ], + }); }); + } + + test('default chat read state uses the legacy session action before a protocol upgrade', async () => { + agentHost.initializeResult.set({ ...agentHost.initializeResult.get(), protocolVersion: '0.8.0' }, undefined); + const provider = createProvider(disposables, agentHost); + const session = setupMultiChatSession(provider, 'chat-read-retry'); + const sessionUri = AgentSession.uri('copilotcli', 'chat-read-retry').toString(); + const defaultChat = buildDefaultChatUri(sessionUri); + agentHost.setSessionState('chat-read-retry', 'copilotcli', makeState([ + makeChatSummary(defaultChat, ''), + ], { defaultChat })); + const main = session.mainChat.get(); + + const legacyAccepted = await provider.setChatReadState(session.sessionId, main.resource, true); + agentHost.initializeResult.set({ ...agentHost.initializeResult.get(), protocolVersion: PROTOCOL_VERSION }, undefined); + const accepted = await provider.setChatReadState(session.sessionId, main.resource, true); assert.deepStrictEqual({ - initial, + legacyAccepted, accepted, - retried, - afterRefresh: { - session: session.isRead.get(), - chats: session.chats.get().map(chat => chat.isRead.get()), - }, - actions: agentHost.dispatchedActions.map(dispatch => ({ - channel: dispatch.channel, - type: dispatch.action.type, - isRead: dispatch.action.type === ActionType.SessionIsReadChanged ? dispatch.action.isRead : undefined, - })), + isRead: main.isRead.get(), + actions: agentHost.dispatchedActions.filter(dispatch => dispatch.action.type === ActionType.ChatIsReadChanged).length, + legacyActions: agentHost.dispatchedActions.filter(dispatch => dispatch.action.type === ActionType.SessionIsReadChanged).length, }, { - initial: { session: false, chats: [false, false, false] }, + legacyAccepted: true, accepted: true, - retried: true, - afterRefresh: { session: true, chats: [true, true, true] }, - actions: [ - { - channel: sessionUri, - type: ActionType.SessionIsReadChanged, - isRead: true, - }, - { - channel: sessionUri, - type: ActionType.SessionIsReadChanged, - isRead: true, - }, - ], + isRead: true, + actions: 1, + legacyActions: 1, + }); + }); + + test('legacy peer read state uses the host-supported session aggregate', async () => { + agentHost.initializeResult.set({ ...agentHost.initializeResult.get(), protocolVersion: '0.9.0' }, undefined); + const provider = createProvider(disposables, agentHost); + const session = setupMultiChatSession(provider, 'legacy-peer-read'); + const sessionUri = AgentSession.uri('copilotcli', 'legacy-peer-read').toString(); + const defaultChat = buildDefaultChatUri(sessionUri); + const peerChat = buildChatUri(sessionUri, 'peer'); + agentHost.setSessionState('legacy-peer-read', 'copilotcli', makeState([ + makeChatSummary(defaultChat, ''), + { ...makeChatSummary(peerChat, 'Peer'), origin: { kind: ProtocolChatOriginKind.User } }, + ], { defaultChat })); + const peer = session.chats.get()[1]; + const accepted = await provider.setChatReadState(session.sessionId, peer.resource, true); + assert.deepStrictEqual({ + accepted, sessionRead: session.isRead.get(), mainRead: session.mainChat.get().isRead.get(), peerRead: peer.isRead.get(), + actions: agentHost.dispatchedActions.map(dispatch => ({ channel: dispatch.channel, type: dispatch.action.type })), + }, { + accepted: true, sessionRead: true, mainRead: true, peerRead: true, + actions: [{ channel: sessionUri, type: ActionType.SessionIsReadChanged }], }); }); @@ -9389,8 +9872,8 @@ suite('LocalAgentHostSessionsProvider', () => { agentHost.setAgents([{ provider: 'copilotcli', displayName: 'Copilot', description: '', models: [], capabilities: { multipleChats: { fork: true, sideChat: true } } } as AgentInfo]); const provider = createProvider(disposables, agentHost, undefined, { providerCtor: BackendSchemeTestProvider }); const rawId = 'multi-side-chat-backend-source'; - const session = setupMultiChatSession(provider, rawId); const backendSession = URI.parse(`ahp-session:/${rawId}`); + const session = setupMultiChatSession(provider, rawId, undefined, backendSession); const defaultChat = buildDefaultChatUri(backendSession); const subagentChat = buildSubagentChatUri(backendSession, 'call-1'); const sourceState = makeState([ diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts index 27ce48ea0a5701..fabc1720f438fe 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts @@ -10,10 +10,12 @@ import { CancellationError } from '../../../../../base/common/errors.js'; import { DisposableStore, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../base/common/network.js'; import { isWeb } from '../../../../../base/common/platform.js'; +import { isEqual } from '../../../../../base/common/resources.js'; import { equalsIgnoreCase } from '../../../../../base/common/strings.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { AgentSession } from '../../../../../platform/agentHost/common/agent.js'; +import { IAgentHostService } from '../../../../../platform/agentHost/common/agentService.js'; import { ChangesetKind } from '../../../../../platform/agentHost/common/changesetUri.js'; import { CLOUD_SANDBOX_AGENT_PROVIDER, CLOUD_SANDBOX_SESSION_SCHEME, CloudSandboxAuthenticationRequiredError, cloudSandboxAddress, ICloudSandboxAgentHostService, ICloudSandboxApiService, ICloudSandboxCreatedSession, ICloudSandboxCreateSessionRequest } from '../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; import { readCloudSandboxProjects } from '../../../../../platform/agentHost/common/meta/cloudSandboxProjectMeta.js'; @@ -86,8 +88,9 @@ export class CloudSandboxAgentHostContribution extends CloudSandboxSessionContri @IHostService hostService: IHostService, @IStorageService storageService: IStorageService, @IWorkspaceTrustManagementService workspaceTrustManagementService: IWorkspaceTrustManagementService, + @IAgentHostService localAgentHostService: IAgentHostService, ) { - super(cloudSandboxService, apiService, _remoteService, _sandboxConnectionCustomizations, configurationService, instantiationService, chatSessionsService, logService, chatEntitlementService, hostService, storageService, workspaceTrustManagementService); + super(cloudSandboxService, apiService, _remoteService, _sandboxConnectionCustomizations, configurationService, instantiationService, chatSessionsService, logService, chatEntitlementService, hostService, storageService, workspaceTrustManagementService, localAgentHostService); this._updateRegistration(); this._register(this._agentHostFilterService.registerDiscoveryHandler(() => this._discoverAndSeed())); this._register(this._agentHostFilterService.onDidChange(() => { @@ -143,8 +146,8 @@ export class CloudSandboxAgentHostContribution extends CloudSandboxSessionContri connectOnDemand: async () => { await this.connect({ environmentId: env.environmentId, sessionId: env.sessionId, name: env.name }); }, disconnectOnDemand: () => this._disconnectEnvironment(cloudSandboxAddress(env.environmentId)), deleteSessionsOnDemand: { - ownsSession: rawId => this._ownsSandboxSession(cloudSandboxAddress(env.environmentId), rawId), - deleteSessions: sessionIds => this._deleteSandboxSession(cloudSandboxAddress(env.environmentId), sessionIds, rawId => provider.removeDeletedSession(rawId)), + ownsSession: session => isEqual(session, AgentSession.uri(CLOUD_SANDBOX_SESSION_SCHEME, AgentSession.id(session))) && this._ownsSandboxSession(cloudSandboxAddress(env.environmentId), AgentSession.id(session)), + deleteSessions: sessions => this._deleteSandboxSession(cloudSandboxAddress(env.environmentId), sessions.map(session => AgentSession.id(session)), rawId => provider.removeDeletedSession(rawId)), }, sessionSchemeAlias: { ui: CLOUD_SANDBOX_AGENT_PROVIDER, backend: CLOUD_SANDBOX_SESSION_SCHEME }, defaultChangesetKind: ChangesetKind.Session, @@ -195,7 +198,8 @@ export class CloudSandboxAgentHostContribution extends CloudSandboxSessionContri } const project = discoveredSessionProject(request.repoNwo); provider.seedProvisionalSession({ - session: AgentSession.uri(CLOUD_SANDBOX_AGENT_PROVIDER, created.sessionId), + session: AgentSession.uri(CLOUD_SANDBOX_SESSION_SCHEME, created.sessionId), + provider: CLOUD_SANDBOX_AGENT_PROVIDER, startTime: now, modifiedTime: now, summary: name, diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxSessionsProvider.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxSessionsProvider.ts index 17bddcaf4b3892..c5a2ffa5f1e6e7 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxSessionsProvider.ts @@ -11,12 +11,12 @@ import { isEqual } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { AgentSession, type IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agent.js'; +import { CLOUD_SANDBOX_AGENT_PROVIDER, CLOUD_SANDBOX_SESSION_SCHEME } from '../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; import { StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import type { ISession } from '../../../../services/sessions/common/session.js'; import type { AgentHostSessionAdapter } from '../../agentHost/browser/baseAgentHostSessionsProvider.js'; import { RemoteAgentHostSessionsProvider } from './remoteAgentHostSessionsProvider.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; -import { CLOUD_SANDBOX_AGENT_PROVIDER } from '../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; import { validateSessionConfigWrite } from '../../../../../platform/agentHost/common/sessionConfigProperties.js'; /** @@ -36,8 +36,15 @@ export class CloudSandboxSessionsProvider extends RemoteAgentHostSessionsProvide /** Sandboxes are per-session environments, not persistent Automation hosts. */ override get automations(): undefined { return undefined; } - private _taskRenameHandler: { readonly rawId: string; readonly rename: (title: string) => Promise } | undefined; - private _taskArchiveHandler: { readonly rawId: string; readonly setArchived: (archived: boolean) => Promise } | undefined; + protected override _adoptCachedSessionMeta(meta: IAgentSessionMetadata): IAgentSessionMetadata { + const adopted = super._adoptCachedSessionMeta(meta); + return adopted.session.scheme === CLOUD_SANDBOX_AGENT_PROVIDER && adopted.provider === CLOUD_SANDBOX_AGENT_PROVIDER + ? { ...adopted, session: adopted.session.with({ scheme: CLOUD_SANDBOX_SESSION_SCHEME }) } + : adopted; + } + + private _taskRenameHandler: { readonly sessionKey: string; readonly rename: (title: string) => Promise } | undefined; + private _taskArchiveHandler: { readonly sessionKey: string; readonly setArchived: (archived: boolean) => Promise } | undefined; private readonly _archiveSequencer = new Sequencer(); /** @@ -47,7 +54,7 @@ export class CloudSandboxSessionsProvider extends RemoteAgentHostSessionsProvide private readonly _withheldSessions = new Set(); /** - * Raw id → deadline after which eviction resumes, or `undefined` while the clock has not + * Backend session key → deadline after which eviction resumes, or `undefined` while the clock has not * started. It starts when a connected host first omits the session, not at seed time, because * waking a sandbox can take minutes. */ @@ -59,7 +66,7 @@ export class CloudSandboxSessionsProvider extends RemoteAgentHostSessionsProvide /** Resolves creation options without requiring the host to know the preallocated session yet. */ async resolveInitialSessionConfig(sessionId: string, values: Record, token: CancellationToken): Promise> { - const rawId = this._rawIdFromChatId(sessionId); + const rawId = this._sessionKeyFromChatId(sessionId); const connection = this.connection; if (!connection || !rawId || !this._sessionCache.has(rawId)) { throw new Error(localize('cloudSandbox.configUnavailable', "The sandbox connection is unavailable. Your prompt was not sent.")); @@ -98,14 +105,14 @@ export class CloudSandboxSessionsProvider extends RemoteAgentHostSessionsProvide protected override updateAdapter(adapter: AgentHostSessionAdapter, meta: IAgentSessionMetadata): boolean { const changed = super.updateAdapter(adapter, meta); - const rawId = AgentSession.id(meta.session); + const sessionKey = meta.session.toString(); // Unlike discovery seeds, this metadata comes from the host's listing or session-added notification. - this._provisionalSessions.delete(rawId); - const title = this._pendingSessionTitles.get(rawId); + this._provisionalSessions.delete(sessionKey); + const title = this._pendingSessionTitles.get(sessionKey); if (title !== undefined && this.connection) { - this._pendingSessionTitles.delete(rawId); + this._pendingSessionTitles.delete(sessionKey); void super.renameSession(adapter.sessionId, title).catch(error => { - this._logService.error(`[CloudSandboxSessionsProvider] Failed to apply initial title for ${rawId}`, error); + this._logService.error(`[CloudSandboxSessionsProvider] Failed to apply initial title for ${sessionKey}`, error); }); return true; } @@ -117,41 +124,41 @@ export class CloudSandboxSessionsProvider extends RemoteAgentHostSessionsProvide this._pendingSessionTitles.delete(rawId); } - protected override _resolveArchivedState(rawId: string, isArchived: boolean): boolean { - return this._taskArchiveHandler?.rawId === rawId - ? this._sessionCache.get(rawId)?.isArchived.get() ?? isArchived - : super._resolveArchivedState(rawId, isArchived); + protected override _resolveArchivedState(sessionKey: string, isArchived: boolean): boolean { + return this._taskArchiveHandler?.sessionKey === sessionKey + ? this._sessionCache.get(sessionKey)?.isArchived.get() ?? isArchived + : super._resolveArchivedState(sessionKey, isArchived); } /** Bind the discovered session's Mission Control archive operation. */ setTaskArchiveHandler(rawId: string, setArchived: (archived: boolean) => Promise): void { - this._taskArchiveHandler = { rawId, setArchived }; + this._taskArchiveHandler = { sessionKey: AgentSession.uri(CLOUD_SANDBOX_SESSION_SCHEME, rawId).toString(), setArchived }; } /** Bind the discovered session's Mission Control rename operation. */ setTaskRenameHandler(rawId: string, rename: (title: string) => Promise): void { - this._taskRenameHandler = { rawId, rename }; + this._taskRenameHandler = { sessionKey: AgentSession.uri(CLOUD_SANDBOX_SESSION_SCHEME, rawId).toString(), rename }; } override async renameSession(sessionId: string, title: string): Promise { - const rawId = this._rawIdFromChatId(sessionId); - const session = rawId ? this._sessionCache.get(rawId) : undefined; - if (!session || !rawId) { + const sessionKey = this._sessionKeyFromChatId(sessionId); + const session = sessionKey ? this._sessionCache.get(sessionKey) : undefined; + if (!session || !sessionKey) { throw new Error(localize('cloudSandbox.sessionNotFound', "Sandbox session not found.")); } const handler = this._taskRenameHandler; - if (handler?.rawId !== rawId) { - if (!this.connection && !this._provisionalSessions.has(rawId)) { + if (handler?.sessionKey !== sessionKey) { + if (!this.connection && !this._provisionalSessions.has(sessionKey)) { throw new Error(localize('cloudSandbox.renameUnavailable', "Connect to the environment to rename this session.")); } } else { await handler.rename(title); - if (this._store.isDisposed || this._sessionCache.get(rawId) !== session) { + if (this._store.isDisposed || this._sessionCache.get(sessionKey) !== session) { throw new CancellationError(); } } - if (this._provisionalSessions.has(rawId)) { - this._pendingSessionTitles.set(rawId, title); + if (this._provisionalSessions.has(sessionKey)) { + this._pendingSessionTitles.set(sessionKey, title); } else if (this.connection) { return super.renameSession(sessionId, title); } @@ -171,27 +178,27 @@ export class CloudSandboxSessionsProvider extends RemoteAgentHostSessionsProvide if (this._store.isDisposed) { throw new CancellationError(); } - const rawId = this._rawIdFromChatId(sessionId); - const session = rawId ? this._sessionCache.get(rawId) : undefined; - if (!session || !rawId) { + const sessionKey = this._sessionKeyFromChatId(sessionId); + const session = sessionKey ? this._sessionCache.get(sessionKey) : undefined; + if (!session || !sessionKey) { throw new Error(localize('cloudSandbox.sessionNotFound', "Sandbox session not found.")); } const handler = this._taskArchiveHandler; - if (handler?.rawId !== rawId) { + if (handler?.sessionKey !== sessionKey) { if (!this.connection) { throw new Error(localize('cloudSandbox.archiveUnavailable', "Connect to the environment to change this session's archive state.")); } return isArchived ? super.archiveSession(sessionId) : super.unarchiveSession(sessionId); } await handler.setArchived(isArchived); - if (this._store.isDisposed || this._sessionCache.get(rawId) !== session) { + if (this._store.isDisposed || this._sessionCache.get(sessionKey) !== session) { throw new CancellationError(); } - this.setSessionArchived(rawId, isArchived); + this.setSessionArchived(AgentSession.id(session.backendUri), isArchived); } setSessionArchived(rawId: string, archived: boolean): void { - const session = this._sessionCache.get(rawId); + const session = this._sessionCache.get(AgentSession.uri(CLOUD_SANDBOX_SESSION_SCHEME, rawId).toString()); if (!session) { throw new Error(localize('cloudSandbox.sessionNotFound', "Sandbox session not found.")); } @@ -211,15 +218,15 @@ export class CloudSandboxSessionsProvider extends RemoteAgentHostSessionsProvide const meta = this._adoptSessionMeta(rawMeta); const rawId = AgentSession.id(meta.session); this._storageService.store(this._localSessionStorageKey(rawId), true, StorageScope.PROFILE, StorageTarget.MACHINE); - if (this._sessionCache.has(rawId)) { + if (this._sessionCache.has(meta.session.toString())) { return; } const adapter = this.createAdapter(meta); adapter.updateDiscoveryMetadata(meta); - this._sessionCache.set(rawId, adapter); - this._withheldSessions.add(rawId); + this._sessionCache.set(meta.session.toString(), adapter); + this._withheldSessions.add(meta.session.toString()); // No deadline yet: the clock starts when the host first omits it. - this._provisionalSessions.set(rawId, undefined); + this._provisionalSessions.set(meta.session.toString(), undefined); } /** @@ -230,10 +237,10 @@ export class CloudSandboxSessionsProvider extends RemoteAgentHostSessionsProvide * a placeholder row and reveal this one. */ publishWithheldSession(rawId: string, options?: { announce?: boolean }): void { - if (!this._withheldSessions.delete(rawId)) { + if (!this._withheldSessions.delete(AgentSession.uri(CLOUD_SANDBOX_SESSION_SCHEME, rawId).toString())) { return; } - const session = this._sessionCache.get(rawId); + const session = this.getCachedSession(rawId); if (session && options?.announce !== false) { this._onDidChangeSessions.fire({ added: [session], removed: [], changed: [] }); } @@ -244,7 +251,7 @@ export class CloudSandboxSessionsProvider extends RemoteAgentHostSessionsProvide * which callers that seeded a session need before it is listed. */ getCachedSession(rawId: string): ISession | undefined { - return this._sessionCache.get(rawId); + return this._sessionCache.get(AgentSession.uri(CLOUD_SANDBOX_SESSION_SCHEME, rawId).toString()); } getSessionModifiedTime(rawId: string): number | undefined { @@ -252,10 +259,13 @@ export class CloudSandboxSessionsProvider extends RemoteAgentHostSessionsProvide } removeDeletedSession(rawId: string): void { - const session = this._removeCachedSession(rawId); - this._withheldSessions.delete(rawId); - this._provisionalSessions.delete(rawId); - this._pendingSessionTitles.delete(rawId); + const cached = this.getCachedSession(rawId); + const key = cached ? this._sessionKeyFromChatId(cached.sessionId) : undefined; + const session = key ? this._removeCachedSession(key) : undefined; + const taskKey = AgentSession.uri(CLOUD_SANDBOX_SESSION_SCHEME, rawId).toString(); + this._withheldSessions.delete(taskKey); + this._provisionalSessions.delete(taskKey); + this._pendingSessionTitles.delete(taskKey); if (session) { this._onDidChangeSessions.fire({ added: [], removed: [session], changed: [] }); session.dispose(); @@ -266,7 +276,7 @@ export class CloudSandboxSessionsProvider extends RemoteAgentHostSessionsProvide const sessions = super.getSessions(); return this._withheldSessions.size === 0 ? sessions - : sessions.filter(session => !this._withheldSessions.has(AgentSession.id(session.resource))); + : sessions.filter(session => !this._withheldSessions.has(this._sessionKeyFromChatId(session.sessionId) ?? '')); } protected override _isSessionEvictable(rawId: string): boolean { diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/entryDrivenProviderContribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/entryDrivenProviderContribution.ts index be251c584e98e5..96935a24d6c55a 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/entryDrivenProviderContribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/entryDrivenProviderContribution.ts @@ -11,11 +11,12 @@ import { type IInstantiationService } from '../../../../../platform/instantiatio import { type INotificationService } from '../../../../../platform/notification/common/notification.js'; import { type IAgentHostAutoConnect, type IAgentHostConnectProgress } from '../../../../common/agentHostSessionsProvider.js'; import { type ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; -import { RemoteAgentHostSessionsProvider } from './remoteAgentHostSessionsProvider.js'; +import { RemoteAgentHostSessionsProvider, type IRemoteAgentHostSessionsProviderConfig } from './remoteAgentHostSessionsProvider.js'; import { watchForIncompatibleNotifications } from './remoteHostOptions.js'; /** Options supplied by a remote-host kind when creating its sessions provider. */ export interface IEntryDrivenProviderOptions { + readonly sessionSchemeAlias?: IRemoteAgentHostSessionsProviderConfig['sessionSchemeAlias']; readonly connectOnDemand?: () => Promise; readonly disconnectOnDemand?: () => Promise; readonly onDidReportConnectProgress?: Event; @@ -102,6 +103,7 @@ export abstract class EntryDrivenProviderContribution extends Disposable { onDidReportConnectProgress: options.onDidReportConnectProgress, autoConnect: options.autoConnect, preferenceKey: options.preferenceKey, + ...(options.sessionSchemeAlias ? { sessionSchemeAlias: options.sessionSchemeAlias } : {}), }); if (options.initialStatus !== undefined) { provider.setConnectionStatus(options.initialStatus); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/missionControlAgentHostContribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/missionControlAgentHostContribution.ts new file mode 100644 index 00000000000000..8564081418d3d6 --- /dev/null +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/missionControlAgentHostContribution.ts @@ -0,0 +1,72 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { localize } from '../../../../../nls.js'; +import { MenuRegistry } from '../../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { INotificationService } from '../../../../../platform/notification/common/notification.js'; +import { ICloudSandboxAgentHostService } from '../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; +import { getEntryAddress, IRemoteAgentHostService, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId, type IRemoteAgentHostEntry } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../workbench/common/contributions.js'; +import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; +import { EntryDrivenProviderContribution, type IEntryDrivenProviderOptions } from './entryDrivenProviderContribution.js'; +import { Menus } from '../../../../browser/menus.js'; +import { ConnectMissionControlEnvironmentCommand } from '../../../../../workbench/contrib/chat/browser/remoteAgentHost/missionControlEnvironmentActions.js'; + +/** User-local MC hosts use the native provider, never the sandbox's task-history adapter. */ +export class MissionControlAgentHostContribution extends EntryDrivenProviderContribution { + static readonly ID = 'workbench.contrib.missionControlAgentHosts'; + protected readonly _entryType = RemoteAgentHostEntryType.CloudSandbox; + + constructor( + @IRemoteAgentHostService remoteAgentHostService: IRemoteAgentHostService, + @IConfigurationService configurationService: IConfigurationService, + @IInstantiationService instantiationService: IInstantiationService, + @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, + @INotificationService notificationService: INotificationService, + @ICloudSandboxAgentHostService private readonly _connections: ICloudSandboxAgentHostService, + ) { + super(remoteAgentHostService, configurationService, instantiationService, sessionsProvidersService, notificationService); + this._register(remoteAgentHostService.onDidChangeConfiguredEntries(() => this._reconcile())); + this._register(remoteAgentHostService.onDidChangeConnections(() => this._reconcile())); + this._register(configurationService.onDidChangeConfiguration(() => this._reconcile())); + this._reconcile(); + } + + protected override _getProviderEntries(): readonly IRemoteAgentHostEntry[] { + return super._getProviderEntries().filter(entry => { + if (entry.connection.type !== RemoteAgentHostEntryType.CloudSandbox || entry.connection.environmentKind !== 'user-local') { + return false; + } + const address = getEntryAddress(entry); + const root = this._remoteAgentHostService.getConnection(address)?.rootState.value; + return this._providerInstances.has(address) || (!!root && !(root instanceof Error)); + }); + } + + protected _getProviderOptions(entry: IRemoteAgentHostEntry): IEntryDrivenProviderOptions { + if (entry.connection.type !== RemoteAgentHostEntryType.CloudSandbox) { + throw new Error('Mission Control provider requires an MC connection.'); + } + const options = { environmentId: entry.connection.environmentId, name: entry.name, environmentKind: 'user-local' as const }; + return { + connectOnDemand: async () => { await this._connections.connect(options, CancellationToken.None); }, + disconnectOnDemand: () => this._connections.disconnect(options.environmentId), + }; + } +} + +registerWorkbenchContribution2(MissionControlAgentHostContribution.ID, MissionControlAgentHostContribution, WorkbenchPhase.AfterRestored); +MenuRegistry.appendMenuItem(Menus.SessionWorkspaceManage, { + command: { id: ConnectMissionControlEnvironmentCommand, title: localize('connectMissionControlHost', "Connect to Mission Control Environment..."), icon: Codicon.cloud }, + when: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals(`config.${RemoteAgentHostsEnabledSettingId}`, true)), + group: '1_add', + order: 5, +}); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts index e6965d7b662954..0dad5542882c3e 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -14,6 +14,7 @@ import { Registry } from '../../../../../platform/registry/common/platform.js'; import { OpenAgentHostStateFileAction } from '../../agentHost/browser/openAgentHostStateFileAction.js'; import '../../../../../workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHost.contribution.js'; import './remoteAgentHostActions.js'; +import './missionControlAgentHostContribution.js'; import './manageRemoteAgentHosts.js'; import '../../agentHost/browser/agentHostAgentPicker.js'; diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index 305e0f27ad1884..4292932935732d 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -5,6 +5,7 @@ import { raceTimeout, RunOnceScheduler } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { isPassiveRelayConnection } from '../../../../../platform/agentHost/common/meta/relayConnectionMeta.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; @@ -84,10 +85,10 @@ export interface IRemoteAgentHostSessionsProviderConfig { readonly disconnectOnDemand?: () => Promise; /** Optional hook to permanently remove the host from its provider inventory. */ readonly removeOnDemand?: () => Promise; - /** Optional owner-managed deletion for selected raw session IDs, working without a host connection. */ + /** Optional owner-managed deletion for exact backend resources, working without a host connection. */ readonly deleteSessionsOnDemand?: { - ownsSession(sessionId: string): boolean; - deleteSessions(sessionIds: readonly string[]): Promise; + ownsSession(session: URI): boolean; + deleteSessions(sessions: readonly URI[]): Promise; }; /** Optional progress messages during on-demand connect. */ readonly onDidReportConnectProgress?: Event; @@ -217,6 +218,7 @@ export class RemoteAgentHostSessionsProvider extends DevContainerAgentHostSessio protected get isWebPlatform(): boolean { return isWeb; } private _connection: IAgentConnection | undefined; + private readonly _passiveRelay = observableValue(this, false); private _defaultDirectory: string | undefined; private readonly _connectionListeners = this._register(new DisposableStore()); private readonly _onDidChangeResourceLabelHomes = Event.any(this._onDidChangeSessionsImmediately, this._onDidChangeDraftSessions.event); @@ -259,7 +261,7 @@ export class RemoteAgentHostSessionsProvider extends DevContainerAgentHostSessio @INotificationService private readonly _notificationService: INotificationService, @IStorageService storageService: IStorageService, @IAgentHostService private readonly _localAgentHostService: IAgentHostService, - @IAgentHostConnectionsService agentHostConnectionsService: IAgentHostConnectionsService, + @IAgentHostConnectionsService private readonly agentHostConnectionsService: IAgentHostConnectionsService, @IChatSessionsService chatSessionsService: IChatSessionsService, @IChatService chatService: IChatService, @IChatWidgetService chatWidgetService: IChatWidgetService, @@ -317,14 +319,12 @@ export class RemoteAgentHostSessionsProvider extends DevContainerAgentHostSessio this.autoConnect = config.autoConnect; this.connectionLabels = config.connectionLabels; this.canConnectOnDemand = !!config.connectOnDemand; - this._readOnly = config.readOnlyWhenDisconnected - ? derived(this, reader => { - const status = this._connectionStatus.read(reader); - return RemoteAgentHostConnectionStatus.isDisconnected(status) - || RemoteAgentHostConnectionStatus.isConnecting(status) - || RemoteAgentHostConnectionStatus.isIncompatible(status); - }) - : constObservable(false); + this._readOnly = derived(this, reader => { + const status = this._connectionStatus.read(reader); + return this._passiveRelay.read(reader) || !!config.readOnlyWhenDisconnected && (RemoteAgentHostConnectionStatus.isDisconnected(status) + || RemoteAgentHostConnectionStatus.isConnecting(status) + || RemoteAgentHostConnectionStatus.isIncompatible(status)); + }); this._register(this._onDidChangeResourceLabelHomes(() => this.updateResourceLabelHomes())); if (this._devContainerLifecycle) { this._register(Event.any(this._onDidChangeSessionsImmediately, this._onDidChangeDraftSessions.event)(() => this._scheduleDevContainerStopIfIdle())); @@ -343,6 +343,10 @@ export class RemoteAgentHostSessionsProvider extends DevContainerAgentHostSessio toHost: resource => fromAgentHostUri(resource), fromHost: resource => toAgentHostUri(resource, this._connectionAuthority), resourceSchemeForProvider: provider => this.resourceSchemeForProvider(provider), + sessionResource: resource => this.agentHostConnectionsService.findSessionResource(resource, this._connectionAuthority) + ?? (AgentSession.provider(resource) || this._sessionSchemeAlias?.backend === resource.scheme + ? this.agentHostConnectionsService.getSessionResource(resource, this._connectionAuthority) : undefined), + onDidChangeSessionResolution: this.agentHostConnectionsService.onDidChangeSessionResolution, providerForSessionScheme: scheme => this._sessionSchemeAlias?.backend === scheme ? this._sessionSchemeAlias.ui : scheme, providerForResourceScheme: scheme => { const prefix = remoteAgentHostSessionTypeAuthorityPrefix(this._connectionAuthority); @@ -575,15 +579,16 @@ export class RemoteAgentHostSessionsProvider extends DevContainerAgentHostSessio } override async deleteSessions(sessionIds: readonly string[]): Promise { - const ownerRawIds: string[] = []; + const ownerSessions: URI[] = []; const hostSessionIds: string[] = []; for (const sessionId of sessionIds) { - const rawId = this._rawIdFromChatId(sessionId); - if (rawId && this._deleteSessionsOnDemand?.ownsSession(rawId)) { - if (!this._sessionCache.has(rawId)) { + const key = this._sessionKeyFromChatId(sessionId); + const backendSession = key ? this._sessionCache.get(key)?.backendUri : undefined; + if (backendSession && this._deleteSessionsOnDemand?.ownsSession(backendSession)) { + if (!key || !this._sessionCache.has(key)) { throw new Error(localize('remoteAgentHost.deleteSessionNotFound', "Session not found.")); } - ownerRawIds.push(rawId); + ownerSessions.push(backendSession); } else { hostSessionIds.push(sessionId); } @@ -630,9 +635,9 @@ export class RemoteAgentHostSessionsProvider extends DevContainerAgentHostSessio if (worktreeError) { throw worktreeError; } - if (ownerRawIds.length > 0) { + if (ownerSessions.length > 0) { // Owner deletion can disconnect the host, so finish its AHP deletions first. - await this._deleteSessionsOnDemand?.deleteSessions(ownerRawIds); + await this._deleteSessionsOnDemand?.deleteSessions(ownerSessions); } } @@ -648,7 +653,7 @@ export class RemoteAgentHostSessionsProvider extends DevContainerAgentHostSessio } protected override _onBackendSessionRemoved(rawId: string): void { - const handle = readAgentDevContainerWorktreeMetadata(this._getSessionMetadataByRawId(rawId))?.handle; + const handle = readAgentDevContainerWorktreeMetadata(this._getSessionMetadataByKey(rawId))?.handle; if (handle) { void this._deleteDetachedWorktree(handle).catch(error => this._logService.error(`[${this.id}] Failed to delete detached worktree for remotely removed session '${rawId}'.`, error)); @@ -661,7 +666,7 @@ export class RemoteAgentHostSessionsProvider extends DevContainerAgentHostSessio return; } const activeHandles = [...rawIds] - .map(rawId => readAgentDevContainerWorktreeMetadata(this._getSessionMetadataByRawId(rawId))?.handle) + .map(rawId => readAgentDevContainerWorktreeMetadata(this._getSessionMetadataByKey(rawId))?.handle) .filter((handle): handle is string => !!handle); const reconciliation = ++this._detachedWorktreeReconciliation; void (async () => { @@ -864,7 +869,7 @@ export class RemoteAgentHostSessionsProvider extends DevContainerAgentHostSessio const changed: ISession[] = []; for (const rawMeta of metas) { const meta = this._adoptSessionMeta(rawMeta); - const rawId = AgentSession.id(meta.session); + const rawId = meta.session.toString(); const existing = this._sessionCache.get(rawId); if (existing) { let didChange = false; @@ -912,30 +917,35 @@ export class RemoteAgentHostSessionsProvider extends DevContainerAgentHostSessio return adapter; } + protected override registerBackendSession(backendSession: URI, provider: string): void { + this.agentHostConnectionsService.registerSessionResource(backendSession, this._connectionAuthority, provider); + } + protected override updateAdapter(adapter: AgentHostSessionAdapter, meta: IAgentSessionMetadata): boolean { this._activitySources.set(adapter, { source: 'host' }); return super.updateAdapter(adapter, meta); } /** - * Map a host-reported session URI onto the UI scheme, so the session routes to the agent's - * content provider. The raw id is preserved, so cache keys are unaffected. + * Reads older sandbox cache rows without dropping their exact backend identity. */ protected override _adoptSessionMeta(meta: IAgentSessionMetadata): IAgentSessionMetadata { const alias = this._sessionSchemeAlias; - if (!alias || meta.session.scheme !== alias.backend) { + if (alias && meta.provider === undefined && meta.session.scheme === alias.ui) { + return { ...meta, provider: alias.ui, session: meta.session.with({ scheme: alias.backend }) }; + } + if (!alias || meta.provider || meta.session.scheme !== alias.backend) { return meta; } - return { ...meta, session: meta.session.with({ scheme: alias.ui }) }; + return { ...meta, provider: alias.ui }; } /** - * Inverse of {@link _adoptSessionMeta}: map the UI scheme back to the one the host's session - * registry is keyed by, so backend calls address the URI the host knows. + * Sandbox creation retains its host-specific addressing contract. */ protected override _backendSessionScheme(agentProvider: string): string { const alias = this._sessionSchemeAlias; - return alias && agentProvider === alias.ui ? alias.backend : agentProvider; + return alias && agentProvider === alias.ui ? alias.backend : super._backendSessionScheme(agentProvider); } protected override _logicalSessionTypeForBackendScheme(backendScheme: string): string { @@ -970,6 +980,9 @@ export class RemoteAgentHostSessionsProvider extends DevContainerAgentHostSessio this._connectionListeners.clear(); this._sessionStateSubscriptions.clearAndDisposeAll(); this._connection = connection; + this._connectionListeners.add(autorun(reader => { + this._passiveRelay.set(isPassiveRelayConnection(connection.initializeResult.read(reader)), undefined); + })); this._devContainerIdleSince = undefined; this._connectionChanged.trigger(undefined); this._automationStore.setConnection(connection); @@ -1010,6 +1023,7 @@ export class RemoteAgentHostSessionsProvider extends DevContainerAgentHostSessio this._sessionStateSubscriptions.clearAndDisposeAll(); this._onDidDisconnect.fire(); this._connection = undefined; + this._passiveRelay.set(false, undefined); this._connectionChanged.trigger(undefined); this._automationStore.clearConnection(); this._defaultDirectory = undefined; diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostContribution.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostContribution.test.ts index be36fe84a1faa4..8727f0b3448ad4 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostContribution.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostContribution.test.ts @@ -654,7 +654,7 @@ suite('CloudSandboxAgentHostContribution', () => { const harness = await createContribution(store, [discoveredSession()], { storageService: storage }); const address = cloudSandboxAddress('env-1'); const provider = harness.contribution.stubProviders.get(address)!; - await provider.config.deleteSessionsOnDemand!.deleteSessions(['sess-1']); + await provider.config.deleteSessionsOnDemand!.deleteSessions([URI.parse('ahp-session:/sess-1')]); harness.contribution.dispose(); const restored = await createContribution(store, [], { storageService: storage, @@ -679,7 +679,7 @@ suite('CloudSandboxAgentHostContribution', () => { storageService: storage, deleteTask: async () => { throw new Error('delete rejected'); }, }); const provider = harness.contribution.stubProviders.get(cloudSandboxAddress('env-1'))!; - await assert.rejects(provider.config.deleteSessionsOnDemand!.deleteSessions(['sess-1']), /delete rejected/); + await assert.rejects(provider.config.deleteSessionsOnDemand!.deleteSessions([URI.parse('ahp-session:/sess-1')]), /delete rejected/); const beforeReload = { sessions: provider.seeded.length, disposed: provider.disposed, disconnected: [...harness.disconnectedFrom] }; harness.contribution.dispose(); const restored = await createContribution(store, [], { @@ -693,7 +693,7 @@ suite('CloudSandboxAgentHostContribution', () => { test('stale discovery cannot recreate a deleted task', async () => { const harness = await createContribution(store, [discoveredSession()]); const provider = harness.contribution.stubProviders.get(cloudSandboxAddress('env-1'))!; - await provider.config.deleteSessionsOnDemand!.deleteSessions(['sess-1']); + await provider.config.deleteSessionsOnDemand!.deleteSessions([URI.parse('ahp-session:/sess-1')]); await harness.runDiscovery(); assert.deepStrictEqual({ sameProvider: harness.contribution.stubProviders.get(cloudSandboxAddress('env-1')) === provider, @@ -705,11 +705,13 @@ suite('CloudSandboxAgentHostContribution', () => { const harness = await createContribution(store, [discoveredSession()]); const provider = harness.contribution.stubProviders.get(cloudSandboxAddress('env-1'))!; assert.deepStrictEqual({ - ownsInventorySession: provider.config.deleteSessionsOnDemand!.ownsSession('sess-1'), - ownsOtherSession: provider.config.deleteSessionsOnDemand!.ownsSession('not-the-discovered-session'), + ownsInventorySession: provider.config.deleteSessionsOnDemand!.ownsSession(URI.parse('ahp-session:/sess-1')), + ownsOtherSession: provider.config.deleteSessionsOnDemand!.ownsSession(URI.parse('ahp-session:/not-the-discovered-session')), + ownsCodexWithSameId: provider.config.deleteSessionsOnDemand!.ownsSession(URI.parse('codex:/sess-1')), + ownsDifferentAuthority: provider.config.deleteSessionsOnDemand!.ownsSession(URI.parse('ahp-session://other/sess-1')), deletedTasks: harness.deletedTasks, disposed: provider.disposed, - }, { ownsInventorySession: true, ownsOtherSession: false, deletedTasks: [], disposed: false }); + }, { ownsInventorySession: true, ownsOtherSession: false, ownsCodexWithSameId: false, ownsDifferentAuthority: false, deletedTasks: [], disposed: false }); }); test('seeds the discovered repository so a never-opened session is not workspace-less', async () => { @@ -723,7 +725,7 @@ suite('CloudSandboxAgentHostContribution', () => { summary: m.summary, project: m.project && { uri: m.project.uri.toString(), displayName: m.project.displayName }, })), [{ - session: 'copilot:/sess-1', + session: 'ahp-session:/sess-1', summary: 'Change port to 5555', project: { uri: 'https://github.com/osortega/simple-server', displayName: 'osortega/simple-server' }, }]); @@ -1859,7 +1861,7 @@ suite('CloudSandboxAgentHostContribution provisioning', () => { progress, }, { ids: { taskId: 'task-new', sessionId: 'sess-new', environmentId: 'env-new' }, - seeded: [{ session: 'copilot:/sess-new', summary: 'osortega/simple-server', project: 'osortega/simple-server' }], + seeded: [{ session: 'ahp-session:/sess-new', summary: 'osortega/simple-server', project: 'osortega/simple-server' }], connectedTo: ['env-new'], connectionSource: 'created', resolvedSession: '/sess-new', @@ -2122,6 +2124,6 @@ suite('CloudSandboxAgentHostContribution provisioning', () => { await harness.runDiscovery(); const provider = harness.contribution.stubProviders.get(cloudSandboxAddress('env-new')); - assert.deepStrictEqual(provider?.seeded.map(m => m.session.toString()), ['copilot:/sess-new']); + assert.deepStrictEqual(provider?.seeded.map(m => m.session.toString()), ['ahp-session:/sess-new']); }); }); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/connectionDiagnostics.fixture.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/connectionDiagnostics.fixture.ts index e8184cd6bee219..34f856b24eefc1 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/connectionDiagnostics.fixture.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/connectionDiagnostics.fixture.ts @@ -10,7 +10,7 @@ import { Disposable, toDisposable } from '../../../../../../base/common/lifecycl import { mock } from '../../../../../../base/test/common/mock.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; import { IChatEntitlementService } from '../../../../../../workbench/services/chat/common/chatEntitlementService.js'; -import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices, waitForFixtureCondition } from '../../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; import { IAgentHostFilterService } from '../../../../../services/agentHostFilter/common/agentHostFilter.js'; import { IConnectionDiagnosticsService, IConnectionDiagnosticsSnapshot, ShowConnectionDiagnosticsCommandId } from '../../browser/connectionDiagnostics.js'; import { showConnectionDiagnosticsSheet } from '../../browser/connectionDiagnosticsReport.js'; @@ -91,7 +91,7 @@ const snapshot: IConnectionDiagnosticsSnapshot = { ].join('\n'), }; -function renderReport(context: ComponentFixtureContext, width: number, expandClient = false): void { +async function renderReport(context: ComponentFixtureContext, width: number, expandClient = false): Promise { const { container, disposableStore, theme } = context; container.classList.add('monaco-workbench'); container.style.width = `${width}px`; @@ -180,6 +180,19 @@ function renderReport(context: ComponentFixtureContext, width: number, expandCli return Disposable.None; }, }); + let previousLayout: string | undefined; + await waitForFixtureCondition(() => { + const content = container.querySelector('.connection-diagnostics-content'); + const scrollable = container.querySelector('.connection-diagnostics-scrollable'); + if (!content || !scrollable || content.clientHeight === 0) { + return false; + } + const layout = JSON.stringify([content.clientHeight, content.scrollHeight, content.scrollTop, scrollable.clientHeight]); + const unchanged = layout === previousLayout; + previousLayout = layout; + const scrollbarWillHide = !scrollable.matches(':hover') && scrollable.querySelector('.scrollbar.visible'); + return unchanged && !scrollbarWillHide; + }, 'Connection diagnostics fixture did not settle its layout and scrollbar'); } function renderEmptyPicker(context: ComponentFixtureContext): void { @@ -208,7 +221,7 @@ function renderEmptyPicker(context: ComponentFixtureContext): void { override async executeCommand(id: string): Promise { if (id === ShowConnectionDiagnosticsCommandId) { dom.clearNode(container); - renderReport(context, 390); + await renderReport(context, 390); } return undefined as T; } @@ -221,11 +234,19 @@ function renderEmptyPicker(context: ComponentFixtureContext): void { trigger.querySelector('.agent-host-filter-dropdown')!.click(); } +function defineReportFixture(width: number, expandClient = false): ReturnType { + return defineComponentFixture({ + deferPaint: true, + virtualTime: { enabled: false }, + render: context => renderReport(context, width, expandClient), + }); +} + export default defineThemedFixtureGroup({ path: 'sessions/connectionDiagnostics/' }, { - MobileDismissedDiscovery: defineComponentFixture({ render: context => renderReport(context, 390) }), - NarrowDismissedDiscovery: defineComponentFixture({ render: context => renderReport(context, 320) }), - DesktopDismissedDiscovery: defineComponentFixture({ render: context => renderReport(context, 720) }), - ClientExpanded: defineComponentFixture({ render: context => renderReport(context, 390, true) }), - NarrowClientExpanded: defineComponentFixture({ render: context => renderReport(context, 320, true) }), + MobileDismissedDiscovery: defineReportFixture(390), + NarrowDismissedDiscovery: defineReportFixture(320), + DesktopDismissedDiscovery: defineReportFixture(720), + ClientExpanded: defineReportFixture(390, true), + NarrowClientExpanded: defineReportFixture(320, true), EmptyHostPicker: defineComponentFixture({ render: renderEmptyPicker }), }); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHost.contribution.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHost.contribution.test.ts index 094552b53408c0..7a35e5d1deae3f 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHost.contribution.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHost.contribution.test.ts @@ -38,6 +38,7 @@ import { CloudSandboxAgentHostService } from '../../../../../../workbench/contri import { createCloudSandboxConnectionCustomization } from '../../../../../../workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxConnectionCustomization.js'; import { SSHAgentHostContribution } from '../../browser/sshAgentHost.contribution.js'; import { WebSocketAgentHostContribution } from '../../browser/webSocketAgentHost.contribution.js'; +import { MissionControlAgentHostContribution } from '../../browser/missionControlAgentHostContribution.js'; import '../../browser/remoteAgentHost.contribution.js'; interface IRemoteAuthenticationState { @@ -554,6 +555,13 @@ interface IProviderOwnerHarness { _reconcileProviders(): void; } +interface IMissionControlProviderOwnerHarness extends IProviderOwnerHarness { + _remoteAgentHostService: IProviderOwnerHarness['_remoteAgentHostService'] & { + getConnection(address: string): Pick | undefined; + }; + _getProviderEntries(): readonly IRemoteAgentHostEntry[]; +} + interface IRemoteAgentRegistrationHarness { _connections: Map; @@ -589,6 +597,20 @@ suite('Remote agent host provider ownership', () => { ], [CloudSandboxApiService, CloudSandboxAgentHostService]); }); + test('native MC provider ownership excludes managed sandbox entries without a development-build dependency', () => { + const entries: IRemoteAgentHostEntry[] = [ + { name: 'Native', connection: { type: RemoteAgentHostEntryType.CloudSandbox, address: 'cloudsandbox:native', environmentId: 'native', environmentKind: 'user-local' } }, + { name: 'Sandbox', connection: { type: RemoteAgentHostEntryType.CloudSandbox, address: 'cloudsandbox:sandbox', environmentId: 'sandbox' } }, + ]; + const owner = Object.create(MissionControlAgentHostContribution.prototype) as IMissionControlProviderOwnerHarness; + owner._configurationService = { getValue: () => true }; + owner._entryType = RemoteAgentHostEntryType.CloudSandbox; + owner._providerInstances = new Map([['cloudsandbox:native', { label: 'Native', defaultLabel: 'Native' }]]); + owner._remoteAgentHostService = { configuredEntries: entries, getConnection: () => undefined }; + assert.deepStrictEqual(owner._getProviderEntries(), [entries[0]]); + assert.deepStrictEqual(Object.keys(owner._getProviderOptions(entries[0])), ['connectOnDemand', 'disconnectOnDemand']); + }); + test('gives WebSocket and SSH entries distinct owners while the shared contribution registers none', () => { const entries: IRemoteAgentHostEntry[] = [ { name: 'Tunnel', connection: { type: RemoteAgentHostEntryType.Tunnel, tunnelId: 'my-tunnel', clusterId: 'usw2' } }, diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index dbc734705813f4..8d4b7cee0370c4 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -13,12 +13,15 @@ import { Emitter, Event } from '../../../../../../base/common/event.js'; import { CancellationError } from '../../../../../../base/common/errors.js'; import { Disposable, DisposableStore, toDisposable, type IReference } from '../../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../../base/common/uri.js'; +import { ResourceMap } from '../../../../../../base/common/map.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 { AgentSession, type IAgentCreateSessionConfig, type IAgentSessionMetadata } from '../../../../../../platform/agentHost/common/agent.js'; +import { AgentSession, type IAgentCreateChatRequestOptions, type IAgentCreateSessionConfig, type IAgentSessionMetadata } from '../../../../../../platform/agentHost/common/agent.js'; import { IAgentHostConnectionsService, type IAgentHostSessionResolutionPolicy, type IAgentHostSessionSchemeAlias } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { getAgentHostExtensionInitializeResultMeta } from '../../../../../../platform/agentHost/common/agentHostExtensionProtocol.js'; +import type { InitializeResult } from '../../../../../../platform/agentHost/common/state/protocol/common/commands.js'; +import { PROTOCOL_VERSION } from '../../../../../../platform/agentHost/common/state/protocol/version/registry.js'; import { agentHostAuthority, toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { ChangesetKind } from '../../../../../../platform/agentHost/common/changesetUri.js'; import { IAgentHostService, type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; @@ -28,8 +31,7 @@ import { AgentHostTransportFailureReason } from '../../../../../../platform/agen import { SessionArtifactType, withSessionArtifacts } from '../../../../../../platform/agentHost/common/sessionArtifacts.js'; import type { ResolveSessionConfigResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; import { ChangesetStatus, CustomizationType, MessageKind, ResponsePartKind, SessionLifecycle, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, type AgentCustomization, type AgentInfo, type AutomationState, type ChangesetFile, type ChangesetState, type ChatState, type RootState, type SessionConfigState, type SessionState } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; -import { PROTOCOL_VERSION } from '../../../../../../platform/agentHost/common/state/protocol/version/registry.js'; -import { ActionType, NotificationType, type ActionEnvelope, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type INotification, type ClientAnnotationsAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; +import { ActionType, NotificationType, type ActionEnvelope, type IRootConfigChangedAction, type ChatAction, type SessionAction, type TerminalAction, type INotification, type ClientAnnotationsAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { buildChatUri, buildDefaultChatUri, createChatState, isAhpAutomationCatalogChannel, SessionStatus as ProtocolSessionStatus, StateComponents, withSessionExternal } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import type { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ConfigurationTarget, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; @@ -84,17 +86,19 @@ class MockAgentConnection extends mock() { private readonly _onDidRootStateChange = new Emitter(); private _rootStateValue: RootState = { agents: [{ provider: 'copilotcli', displayName: 'Copilot', description: '', models: [] } as AgentInfo] }; override readonly rootState: IAgentSubscription; - override readonly initializeResult = constObservable({ + readonly handshakeState = observableValue(this, { protocolVersion: PROTOCOL_VERSION, serverSeq: 0, snapshots: [], + _meta: { 'vscode.agentHost': true } as Record, automations: { create: {}, schedules: {}, runCancellation: {} }, }); + override readonly initializeResult: IAgentConnection['initializeResult'] = this.handshakeState; override readonly clientId = 'test-client-1'; private readonly _sessions = new Map(); public disposedSessions: URI[] = []; - public dispatchedActions: { channel: string; action: SessionAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction; clientId: string; clientSeq: number }[] = []; + public dispatchedActions: { channel: string; action: ChatAction | SessionAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction; clientId: string; clientSeq: number }[] = []; public failResolveSessionConfig = false; public echoDispatchedActions = false; public dispatchedActionRejectionReason: string | undefined; @@ -149,11 +153,11 @@ class MockAgentConnection extends mock() { return { items: [] }; } - dispatchAction(channel: string, action: SessionAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction, clientId: string, clientSeq: number): void { + dispatchAction(channel: string, action: ChatAction | SessionAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction, clientId: string, clientSeq: number): void { this.dispatchedActions.push({ channel, action, clientId, clientSeq }); } - override dispatch(channel: string, action: SessionAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction): void { + override dispatch(channel: string, action: ChatAction | SessionAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction): void { this.dispatchedActions.push({ channel, action, clientId: this.clientId, clientSeq: this._nextSeq++ }); if (this.echoDispatchedActions) { queueMicrotask(() => this.fireAction({ channel, action, serverSeq: this._nextSeq++, origin: undefined, rejectionReason: this.dispatchedActionRejectionReason } as ActionEnvelope)); @@ -264,9 +268,10 @@ class MockAgentConnection extends mock() { // ---- Test helpers ----------------------------------------------------------- -function createSession(id: string, opts?: { provider?: string; summary?: string; project?: { uri: URI; displayName: string }; workingDirectory?: URI; startTime?: number; modifiedTime?: number; status?: ProtocolSessionStatus; _meta?: IAgentSessionMetadata['_meta'] }): IAgentSessionMetadata { +function createSession(id: string, opts?: { session?: URI; provider?: string; summary?: string; project?: { uri: URI; displayName: string }; workingDirectory?: URI; startTime?: number; modifiedTime?: number; status?: ProtocolSessionStatus; _meta?: IAgentSessionMetadata['_meta'] }): IAgentSessionMetadata { return { - session: AgentSession.uri(opts?.provider ?? 'copilotcli', id), + session: opts?.session ?? AgentSession.uri(opts?.provider ?? 'copilotcli', id), + ...(opts?.session ? { provider: opts.provider ?? 'copilotcli' } : {}), startTime: opts?.startTime ?? 1000, modifiedTime: opts?.modifiedTime ?? 2000, summary: opts?.summary, @@ -313,7 +318,20 @@ function createProvider(disposables: DisposableStore, connection: MockAgentConne instantiationService.stub(IStorageService, overrides?.storageService ?? disposables.add(new InMemoryStorageService())); instantiationService.stub(IAgentHostService, overrides?.localAgentHostService ?? new class extends mock() { }()); instantiationService.stub(IRemoteAgentHostService, overrides?.remoteAgentHostService ?? new NullRemoteAgentHostService()); + const sessionResources = new ResourceMap(); + const sessionResourcesChanged = disposables.add(new Emitter()); instantiationService.stub(IAgentHostConnectionsService, upcastPartial({ + findSessionResource: backend => sessionResources.get(backend), + onDidChangeSessionResolution: sessionResourcesChanged.event, + registerSessionResource: (backend, authority, provider) => { + const resource = backend.with({ scheme: `remote-${authority}-${provider ?? backend.scheme}` }); + if (!sessionResources.has(backend)) { + sessionResources.set(backend, resource); + sessionResourcesChanged.fire(); + } + return resource; + }, + getSessionResource: backend => sessionResources.get(backend), registerSessionResolutionPolicy: (authority, policy) => { overrides?.sessionResolutionPolicies?.push({ authority, policy }); return Disposable.None; @@ -395,9 +413,9 @@ async function waitForSessionConfig(provider: RemoteAgentHostSessionsProvider, s }); } -function fireSessionAdded(connection: MockAgentConnection, rawId: string, opts?: { provider?: string; title?: string; project?: { uri: string; displayName: string }; workingDirectory?: string; createdAt?: string; modifiedAt?: string; metadata?: Record; status?: ProtocolSessionStatus }): void { +function fireSessionAdded(connection: MockAgentConnection, rawId: string, opts?: { session?: URI; provider?: string; title?: string; project?: { uri: string; displayName: string }; workingDirectory?: string; createdAt?: string; modifiedAt?: string; metadata?: Record; status?: ProtocolSessionStatus }): void { const provider = opts?.provider ?? 'copilotcli'; - const sessionUri = AgentSession.uri(provider, rawId); + const sessionUri = opts?.session ?? AgentSession.uri(provider, rawId); connection.fireNotification({ channel: 'ahp-root://', type: NotificationType.SessionAdded, @@ -448,6 +466,45 @@ suite('RemoteAgentHostSessionsProvider', () => { ensureNoDisposablesAreLeakedInTestSuite(); + for (const protocolVersion of ['0.9.0', '0.10.0']) { + test(`marks the default chat read with protocol ${protocolVersion}`, async () => { + connection.handshakeState.set({ ...connection.handshakeState.get(), protocolVersion }, undefined); + const backend = URI.parse('ahp-session://tenant/read-state'); + const chat = URI.parse('conversation://tenant/main-read'); + connection.addSession({ + ...createSession('read-state', { status: ProtocolSessionStatus.Idle }), + session: backend, + provider: 'copilotcli', + chats: [{ chat, kind: 'default', isRead: false }], + }); + const provider = createProvider(disposables, connection); + await timeout(0); + const session = provider.getSessions()[0]; + provider.getSessionConfig(session.sessionId); + connection.setChannelState(backend, upcastPartial({ + provider: 'copilotcli', + title: 'Read state', + status: ProtocolSessionStatus.Idle, + lifecycle: SessionLifecycle.Ready, + activeClients: [], + defaultChat: chat.toString(), + chats: [{ resource: chat.toString(), title: 'Default', status: ProtocolSessionStatus.Idle, modifiedAt: new Date(0).toISOString() }], + })); + const before = session.mainChat.get().isRead.get(); + const accepted = await provider.setChatReadState(session.sessionId, session.mainChat.get().resource, true); + assert.deepStrictEqual({ + before, accepted, read: session.mainChat.get().isRead.get(), + actions: connection.dispatchedActions.map(action => ({ channel: action.channel, type: action.action.type })), + }, { + before: false, accepted: true, read: true, + actions: [{ + channel: protocolVersion === '0.9.0' ? backend.toString() : chat.toString(), + type: protocolVersion === '0.9.0' ? ActionType.SessionIsReadChanged : ActionType.ChatIsReadChanged + }], + }); + }); + } + // ---- Provider identity ------- test('derives id and label from config, and session types from rootState agents', () => { @@ -808,6 +865,18 @@ suite('RemoteAgentHostSessionsProvider', () => { }); }); + test('relay passive metadata keeps chats read-only and updates when the handshake changes', () => { + const provider = createProvider(disposables, connection); + provider.seedSessions([createSession('passive-session')]); + provider.setConnectionStatus(RemoteAgentHostConnectionStatus.connected); + const chat = provider.getSessions()[0].mainChat.get(); + assert.strictEqual(chat.interactivity.get(), ChatInteractivity.Full); + connection.handshakeState.set({ ...connection.handshakeState.get(), _meta: { 'copilot.passive': true } }, undefined); + assert.strictEqual(chat.interactivity.get(), ChatInteractivity.ReadOnly); + connection.handshakeState.set({ ...connection.handshakeState.get(), _meta: { 'copilot.passive': false } }, undefined); + assert.strictEqual(chat.interactivity.get(), ChatInteractivity.Full); + }); + test('keeps initial connections read-only but permits self-healing reconnects', () => { const provider = createProvider(disposables, connection, { readOnlyWhenDisconnected: true }); provider.seedSessions([createSession('sandbox-session')]); @@ -947,7 +1016,7 @@ suite('RemoteAgentHostSessionsProvider', () => { await timeout(0); // let the eager createSession promise resolve const rawId = session.resource.path.substring(1); - const expectedBackendUri = AgentSession.uri(provider.sessionTypes[0].id, rawId); + const expectedBackendUri = URI.from({ scheme: provider.sessionTypes[0].id, path: `/${rawId}` }); assert.deepStrictEqual( connection.createdSessionUris.map(u => u.toString()), [expectedBackendUri.toString()], @@ -1226,6 +1295,63 @@ suite('RemoteAgentHostSessionsProvider', () => { // ---- Session actions ------- + test('opaque advertised default and peer chat mutations use host resources without native reconstruction', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const disposed: string[] = []; + const created: { session: string; forkSource?: string; sideChatSource?: string }[] = []; + const backend = URI.parse('session-store://tenant/opaque/session?generation%3D2'); + const defaultChat = URI.parse('conversation://tenant/default?revision%3D3#entry'); + const peer = URI.parse('conversation://tenant/peer?revision%3D4#entry'); + let state: SessionState = { + provider: 'copilot', title: 'Opaque', status: ProtocolSessionStatus.Idle, lifecycle: SessionLifecycle.Ready, activeClients: [], + defaultChat: defaultChat.toString(), + chats: [defaultChat, peer].map(resource => createChatState({ resource: resource.toString(), title: '', status: ProtocolSessionStatus.Idle, modifiedAt: new Date(2000).toISOString() })), + }; + const peerConnection = new class extends MockAgentConnection { + override readonly initializeResult = constObservable({ protocolVersion: PROTOCOL_VERSION, serverSeq: 0, snapshots: [] }); + override async disposeChat(resource: URI): Promise { disposed.push(resource.toString()); } + override async createChat(session: URI, chat: URI, options?: IAgentCreateChatRequestOptions): Promise { + created.push({ session: session.toString(), forkSource: options?.fork?.source.toString(), sideChatSource: options?.sideChat?.source.toString() }); + state = { ...state, chats: [...state.chats, createChatState({ resource: chat.toString(), title: '', status: ProtocolSessionStatus.Idle, modifiedAt: new Date(2000).toISOString() })] }; + this.setChannelState(session, state); + } + }(); + disposables.add(toDisposable(() => peerConnection.dispose())); + peerConnection.setAgents([{ provider: 'copilot', displayName: 'Copilot', description: '', models: [], capabilities: { multipleChats: { fork: true, sideChat: true } } } as AgentInfo]); + peerConnection.addSession({ + session: backend, provider: 'copilot', startTime: 1000, modifiedTime: 2000, summary: 'Opaque', + chats: [{ chat: defaultChat, kind: 'default', summary: 'Default' }, { chat: peer, kind: 'peer', summary: 'Peer' }], + }); + peerConnection.setChannelState(backend, state); + const provider = createProvider(disposables, peerConnection); + await timeout(0); + const session = provider.getSessions()[0]; + const peerResource = session.resource.with({ fragment: peer.toString() }); + await provider.forkChat(session.sessionId, session.resource, 'source-turn'); + await provider.createSideChat(session.sessionId, peerResource, 'source-turn'); + await provider.renameChat(session.sessionId, session.resource, 'Renamed default'); + await provider.renameChat(session.sessionId, peerResource, 'Renamed peer'); + await provider.archiveChat(session.sessionId, peerResource); + await assert.rejects(provider.forkChat(session.sessionId, session.resource.with({ fragment: 'unknown-peer' }), 'source-turn'), /The chat could not be found/); + await assert.rejects(provider.createSideChat(session.sessionId, session.resource.with({ fragment: 'unknown-peer' }), 'source-turn'), /The chat could not be found/); + await provider.deleteChat(session.sessionId, peerResource, { skipConfirmation: true }); + assert.deepStrictEqual({ + titles: peerConnection.dispatchedActions.filter(entry => entry.action.type === ActionType.SessionTitleChanged).map(entry => entry.channel), + archives: peerConnection.dispatchedActions.filter(entry => entry.action.type === ActionType.ChatIsArchivedChanged).map(entry => entry.channel), + disposed, + created, + resource: session.resource.toString(), + }, { + titles: [defaultChat.toString(), peer.toString()], + archives: [peer.toString()], + disposed: [peer.toString()], + created: [ + { session: backend.toString(), forkSource: defaultChat.toString(), sideChatSource: undefined }, + { session: backend.toString(), forkSource: undefined, sideChatSource: peer.toString() }, + ], + resource: backend.with({ scheme: 'remote-localhost__4321-copilot' }).toString(), + }); + })); + test('deleteSession calls disposeSession with backend agent URI and removes from cache', async () => { const provider = createProvider(disposables, connection); fireSessionAdded(connection, 'del-sess', { title: 'To Delete' }); @@ -1307,7 +1433,7 @@ suite('RemoteAgentHostSessionsProvider', () => { _meta: getAgentHostExtensionInitializeResultMeta(), }); override async createDetachedWorktree(session: URI, prompt: string) { - events.push(`create:${AgentSession.provider(session)}:${prompt}`); + events.push(`create:${session.scheme}:${prompt}`); return { handle, worktree: URI.file('/worktrees/prepared') }; } override async claimDetachedWorktree(actualHandle: string): Promise { events.push(`claim:${actualHandle}`); } @@ -1397,7 +1523,7 @@ suite('RemoteAgentHostSessionsProvider', () => { replacement: replacement.sessionId, available: false, enabled: false, events: [ `requestTrust:${workspace.toString()}`, - ...useWorktree ? ['create:copilotcli:Fix it', `trust:${worktreeUri.toString()}`] : [], + ...useWorktree ? ['create:ahp-session:Fix it', `trust:${worktreeUri.toString()}`] : [], `connect:${(useWorktree ? worktreeUri : workspace).toString()}`, `trust:${targetWorkspace.toString()}`, `metadata:${useWorktree ? JSON.stringify({ 'vscode.devContainerWorktree': { version: 1, handle } }) : undefined}`, @@ -2695,6 +2821,58 @@ suite('RemoteAgentHostSessionsProvider', () => { assert.deepStrictEqual(loading, [false, true, true, false, false, true]); }); + test('mixed remote cache reloads preserve frontend identities and route same raw IDs to their owning providers', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const storageService = disposables.add(new InMemoryStorageService()); + let listed: IAgentSessionMetadata[] = [ + { ...createSession('shared', { summary: 'Codex', status: ProtocolSessionStatus.IsRead }), session: URI.parse('codex:/shared'), provider: 'codex' }, + { ...createSession('shared', { summary: 'Claude' }), session: URI.parse('claude:/shared'), provider: 'claude' }, + { ...createSession('standard', { summary: 'Standard Copilot' }), session: URI.parse('ahp-session:/standard'), provider: 'copilotcli' }, + { ...createSession('legacy', { summary: 'Legacy Copilot' }), provider: 'copilotcli' }, + ]; + const createConnection = () => new class extends MockAgentConnection { + override async listSessions(): Promise { return listed; } + override async disposeSession(resource: URI): Promise { + this.disposedSessions.push(resource); + listed = listed.filter(metadata => !extUri.isEqual(metadata.session, resource)); + } + }(); + const firstConnection = createConnection(); + disposables.add(toDisposable(() => firstConnection.dispose())); + const first = createProvider(disposables, firstConnection, { storageService }); + await timeout(0); + const snapshot = (provider: RemoteAgentHostSessionsProvider) => provider.getSessions().map(session => ({ + resource: session.resource.toString(), sessionId: session.sessionId, isRead: session.isRead.get(), + })).sort((a, b) => a.resource.localeCompare(b.resource)); + const before = snapshot(first); + await storageService.flush(); + const nextConnection = createConnection(); + disposables.add(toDisposable(() => nextConnection.dispose())); + const restored = createProvider(disposables, nextConnection, { storageService, noConnection: true }); + const after = snapshot(restored); + restored.setConnection(nextConnection); + await timeout(0); + const codex = restored.getSessions().find(session => session.sessionType === 'codex'); + const claude = restored.getSessions().find(session => session.sessionType === 'claude'); + const standard = restored.getSessions().find(session => session.title.get() === 'Standard Copilot'); + const legacy = restored.getSessions().find(session => session.title.get() === 'Legacy Copilot'); + assert.ok(codex && claude && standard && legacy); + await restored.archiveSession(codex.sessionId); + await restored.archiveSession(standard.sessionId); + await restored.archiveSession(legacy.sessionId); + await restored.deleteSessions([claude.sessionId]); + assert.deepStrictEqual({ + reloaded: after, + archived: nextConnection.dispatchedActions.filter(entry => entry.action.type === ActionType.SessionIsArchivedChanged).map(entry => entry.channel), + deleted: nextConnection.disposedSessions.map(resource => resource.toString()), + survivingCodex: restored.getSessions().find(session => session.sessionId === codex.sessionId)?.resource.toString(), + }, { + reloaded: before, + archived: ['codex:/shared', 'ahp-session:/standard', 'copilotcli:/legacy'], + deleted: ['claude:/shared'], + survivingCodex: codex.resource.toString(), + }); + })); + test('unpublishCachedSessions hides sessions but retains persisted cache', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const storageService = disposables.add(new InMemoryStorageService()); connection.addSession(createSession('keep-me', { summary: 'Keep Me' })); @@ -3509,6 +3687,48 @@ suite('CloudSandboxSessionsProvider external sessions', () => { }); suite('CloudSandboxSessionsProvider discovery metadata', () => { + for (const sandbox of [false, true]) { + test(`older sandbox addresses in current caches are normalized only by the sandbox provider (${sandbox})`, async () => { + const storageService = disposables.add(new InMemoryStorageService()); + storageService.store('remoteAgentHost.cachedSessions.v4.localhost__4321', JSON.stringify([{ + session: 'copilot:/cached-address', + provider: 'copilot', + startTime: 1000, + modifiedTime: 2000, + summary: 'Cached', + }]), StorageScope.APPLICATION, StorageTarget.USER); + const connection = disposables.add(new MockAgentConnection()); + const provider = createProvider(disposables, connection, { + noConnection: true, + storageService, + sessionSchemeAlias: { ui: 'copilot', backend: 'ahp-session' }, + ctor: sandbox ? CloudSandboxSessionsProvider : RemoteAgentHostSessionsProvider, + }); + const resource = URI.parse('remote-localhost__4321-copilot:/cached-address'); + const cached = provider.getSessionByResource(resource); + assert.ok(cached instanceof AgentHostSessionAdapter); + const expectedBackend = sandbox ? 'ahp-session:/cached-address' : 'copilot:/cached-address'; + provider.seedSessions([{ + session: URI.parse(expectedBackend), + provider: 'copilot', + startTime: 1000, + modifiedTime: 3000, + summary: 'Discovered', + }]); + assert.deepStrictEqual({ + backend: cached.backendUri.toString(), + sameAdapter: provider.getSessionByResource(resource) === cached, + title: cached.title.get(), + resources: provider.getSessions().map(session => session.resource.toString()), + }, { + backend: expectedBackend, + sameAdapter: true, + title: 'Cached', + resources: [resource.toString()], + }); + }); + } + const disposables = new DisposableStore(); const originalProject = { uri: URI.parse('https://github.com/owner/original'), displayName: 'owner/original' }; const metadata = createSession('discovered-session', { @@ -3996,12 +4216,16 @@ suite('CloudSandboxSessionsProvider discovery status', () => { test('fresh discovery replaces a cached activity placeholder without changing user flags', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const storage = disposables.add(new InMemoryStorageService()); - const metadata = createSession('discovered', { - summary: 'Cached title', - modifiedTime: 1_000, - status: ProtocolSessionStatus.Idle | ProtocolSessionStatus.IsRead, - workingDirectory: URI.file('/remote/project'), - }); + const metadata = { + ...createSession('discovered', { + summary: 'Cached title', + modifiedTime: 1_000, + status: ProtocolSessionStatus.Idle | ProtocolSessionStatus.IsRead, + workingDirectory: URI.file('/remote/project'), + }), + session: AgentSession.uri('ahp-session', 'discovered'), + provider: 'copilot', + }; const first = sandbox(storage); assert.ok(first instanceof CloudSandboxSessionsProvider); first.setTaskArchiveHandler('discovered', async () => { }); @@ -4099,8 +4323,8 @@ suite('CloudSandboxSessionsProvider discovery status', () => { suite('CloudSandboxSessionsProvider renaming', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); - const metadata = createSession('sandbox-session', { provider: 'copilot', summary: 'Old title' }); - const backendUri = metadata.session.with({ scheme: 'ahp-session' }); + const backendUri = AgentSession.uri('ahp-session', 'sandbox-session'); + const metadata = { ...createSession('sandbox-session', { provider: 'copilot', summary: 'Old title' }), session: backendUri, provider: 'copilot' }; class TestSandboxProvider extends CloudSandboxSessionsProvider { refresh(): Promise { return this._refreshSessions(); } @@ -4123,6 +4347,40 @@ suite('CloudSandboxSessionsProvider renaming', () => { return { provider, connection, renamed }; } + for (const nativeProvider of ['copilotcli', 'codex', 'claude']) { + test(`task rename and removal retain a colliding ${nativeProvider} session`, async () => { + const connection = store.add(new MockAgentConnection()); + const provider = createProvider(store.add(new DisposableStore()), connection, { + address: 'cloudsandbox:rename-test', ctor: TestSandboxProvider, + sessionSchemeAlias: { ui: 'copilot', backend: 'ahp-session' }, noConnection: true, + }) as TestSandboxProvider; + const renamed: string[] = []; + provider.setTaskRenameHandler('sandbox-session', async title => { renamed.push(title); }); + provider.seedSessions([createSession('sandbox-session', { provider: nativeProvider }), metadata]); + const other = provider.getSessions().find(session => session instanceof AgentHostSessionAdapter && session.agentProvider === nativeProvider); + const task = provider.getCachedSession('sandbox-session'); + assert.ok(other && task); + const otherTitle = other.title.get(); + + await assert.rejects(provider.renameSession(other.sessionId, 'Other title'), /Connect to the environment/); + provider.removeDeletedSession('sandbox-session'); + + assert.deepStrictEqual({ + taskBackend: task instanceof AgentHostSessionAdapter ? task.backendUri.toString() : undefined, + renamed, + cachedTask: provider.getCachedSession('sandbox-session'), + remaining: provider.getSessions().map(session => session.sessionId), + otherTitleUnchanged: other.title.get() === otherTitle, + }, { + taskBackend: backendUri.toString(), + renamed: [], + cachedTask: undefined, + remaining: [other.sessionId], + otherTitleUnchanged: true, + }); + }); + } + test('renames a disconnected task and its default chat without connecting', async () => { const { provider, connection, renamed } = createSandbox(); const session = provider.getSessions()[0]; @@ -4230,6 +4488,30 @@ suite('CloudSandboxSessionsProvider renaming', () => { suite('CloudSandboxSessionsProvider deletion', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); + for (const targetProvider of ['codex', 'claude']) { + test(`owner-managed deletion retains provider identity for colliding raw IDs (${targetProvider})`, async () => { + const metadata = ['codex', 'claude'].map(provider => createSession('shared', { provider })); + const connection = store.add(new class extends MockAgentConnection { + override async listSessions(): Promise { return metadata; } + }()); + const ownerDeleted: string[] = []; + const provider = createProvider(store.add(new DisposableStore()), connection, { + deleteSessionsOnDemand: { + ownsSession: session => session.toString() === 'codex:/shared', + deleteSessions: async sessions => { ownerDeleted.push(...sessions.map(session => session.toString())); }, + }, + }); + await timeout(0); + const session = provider.getSessions().find(session => session.resource.scheme.endsWith(`-${targetProvider}`)); + assert.ok(session); + await provider.deleteSession(session.sessionId); + assert.deepStrictEqual({ + ownerDeleted, + hostDeleted: connection.disposedSessions.map(session => session.toString()), + }, targetProvider === 'codex' ? { ownerDeleted: ['codex:/shared'], hostDeleted: [] } : { ownerDeleted: [], hostDeleted: ['claude:/shared'] }); + }); + } + for (const targets of [['additional-session'], ['sandbox-session', 'additional-session'], ['additional-session', 'sandbox-session']]) { test(`routes mixed catalogs to their deletion owners: ${targets.join(', ')}`, async () => { const operations: string[] = []; @@ -4249,11 +4531,11 @@ suite('CloudSandboxSessionsProvider deletion', () => { sessionSchemeAlias: { ui: 'copilot', backend: 'ahp-session' }, noConnection: true, deleteSessionsOnDemand: { - ownsSession: id => id === 'sandbox-session', + ownsSession: session => session.toString() === 'ahp-session:/sandbox-session', deleteSessions: async ids => { - operations.push(`missionControl:${ids.join(',')}`); + operations.push(`missionControl:${ids.map(session => AgentSession.id(session)).join(',')}`); for (const id of ids) { - sandbox.removeDeletedSession(id); + sandbox.removeDeletedSession(AgentSession.id(id)); } provider.clearConnection(); }, @@ -4291,8 +4573,8 @@ suite('CloudSandboxSessionsProvider deletion', () => { const provider = createProvider(store.add(new DisposableStore()), connection, { ctor: CloudSandboxSessionsProvider, deleteSessionsOnDemand: { - ownsSession: id => id === 'sandbox-session', - deleteSessions: async ids => { owned.push(...ids); }, + ownsSession: session => session.toString() === 'copilotcli:/sandbox-session', + deleteSessions: async ids => { owned.push(...ids.map(session => session.toString())); }, }, }); provider.seedSessions(metadata); @@ -4317,11 +4599,11 @@ suite('CloudSandboxSessionsProvider deletion', () => { readOnlyWhenDisconnected: true, connectOnDemand: async () => { connects++; }, deleteSessionsOnDemand: { - ownsSession: id => id === 'sandbox-session', + ownsSession: session => session.toString() === 'ahp-session:/sandbox-session', deleteSessions: async ids => { - deleted.push([...ids]); + deleted.push(ids.map(session => AgentSession.id(session))); for (const id of ids) { - sandbox.removeDeletedSession(id); + sandbox.removeDeletedSession(AgentSession.id(id)); } }, }, @@ -4353,7 +4635,7 @@ suite('CloudSandboxSessionsProvider deletion', () => { const provider = createProvider(store.add(new DisposableStore()), connection, { ctor: CloudSandboxSessionsProvider, noConnection: true, deleteSessionsOnDemand: { - ownsSession: id => id === 'sandbox-session', + ownsSession: session => session.toString() === 'copilotcli:/sandbox-session', deleteSessions: async () => { throw new Error('Mission Control rejected deletion'); }, }, }); @@ -4366,8 +4648,12 @@ suite('CloudSandboxSessionsProvider deletion', () => { suite('CloudSandboxSessionsProvider archiving', () => { const disposables = new DisposableStore(); - const metadata = createSession('sandbox-session', { provider: 'copilot', summary: 'Sandbox Session' }); const backendResource = AgentSession.uri('ahp-session', 'sandbox-session'); + const metadata = { + ...createSession('sandbox-session', { provider: 'copilot', summary: 'Sandbox Session' }), + session: backendResource, + provider: 'copilot', + }; let connection: MockAgentConnection; setup(() => { @@ -4393,6 +4679,30 @@ suite('CloudSandboxSessionsProvider archiving', () => { return provider; } + for (const nativeProvider of ['copilotcli', 'codex', 'claude']) { + test(`task archive ownership does not claim a colliding ${nativeProvider} session`, async () => { + const taskArchives: boolean[] = []; + const provider = createSandboxProvider({ setArchived: async archived => { taskArchives.push(archived); } }); + provider.seedSessions([metadata, createSession('sandbox-session', { provider: nativeProvider })]); + const other = provider.getSessions().find(session => session instanceof AgentHostSessionAdapter && session.agentProvider === nativeProvider); + const task = provider.getSessions().find(session => session instanceof AgentHostSessionAdapter && session.agentProvider === 'copilot'); + assert.ok(other && task); + + await assert.rejects(provider.archiveSession(other.sessionId), /Connect to the environment/); + await provider.archiveSession(task.sessionId); + const archived = task.isArchived.get(); + provider.setSessionArchived('sandbox-session', false); + + assert.deepStrictEqual({ + taskArchives, + archived, + unarchived: task.isArchived.get(), + otherArchived: other.isArchived.get(), + hostActions: connection.dispatchedActions, + }, { taskArchives: [true], archived: true, unarchived: false, otherArchived: false, hostActions: [] }); + }); + } + test('archives and unarchives the owning task without connecting or dispatching host actions', async () => { let connectCalls = 0; const taskArchives: boolean[] = []; @@ -4676,8 +4986,8 @@ suite('CloudSandboxSessionsProvider archiving', () => { archived: true, unarchived: false, actions: [ - { channel: 'copilotcli:/remote-session', action: { type: ActionType.SessionIsArchivedChanged, isArchived: true } }, - { channel: 'copilotcli:/remote-session', action: { type: ActionType.SessionIsArchivedChanged, isArchived: false } }, + { channel: AgentSession.uri('copilotcli', 'remote-session').toString(), action: { type: ActionType.SessionIsArchivedChanged, isArchived: true } }, + { channel: AgentSession.uri('copilotcli', 'remote-session').toString(), action: { type: ActionType.SessionIsArchivedChanged, isArchived: false } }, ], }); })); @@ -4782,14 +5092,14 @@ suite('CloudSandboxSessionsProvider provisional sessions', () => { test('a newer rename replaces the queued initial title before host creation', async () => { const provider = createSandboxProvider(disposables, connection, { noConnection: true }); - provider.seedProvisionalSession(createSession('renamed-before-create')); + provider.seedProvisionalSession(createSession('renamed-before-create', { session: AgentSession.uri('ahp-session', 'renamed-before-create') })); const session = provider.getCachedSession('renamed-before-create')!; await provider.renameSession(session.sessionId, 'First prompt'); await provider.renameSession(session.sessionId, 'My chosen title'); provider.setConnection(connection); await timeout(0); - fireSessionAdded(connection, 'renamed-before-create', { title: 'main' }); + fireSessionAdded(connection, 'renamed-before-create', { session: AgentSession.uri('ahp-session', 'renamed-before-create'), title: 'main' }); assert.deepStrictEqual({ title: session.title.get(), @@ -4803,7 +5113,7 @@ suite('CloudSandboxSessionsProvider provisional sessions', () => { for (const removal of ['local deletion', 'host removal'] as const) { test(`a queued title is discarded on ${removal}`, async () => { const provider = createSandboxProvider(disposables, connection, { noConnection: true }); - const metadata = createSession('removed-before-create'); + const metadata = createSession('removed-before-create', { session: AgentSession.uri('ahp-session', 'removed-before-create') }); provider.seedProvisionalSession(metadata); const session = provider.getCachedSession('removed-before-create')!; await provider.renameSession(session.sessionId, 'First prompt'); @@ -4813,10 +5123,10 @@ suite('CloudSandboxSessionsProvider provisional sessions', () => { if (removal === 'local deletion') { provider.removeDeletedSession('removed-before-create'); } else { - fireSessionRemoved(connection, 'removed-before-create'); + fireSessionRemoved(connection, 'removed-before-create', 'ahp-session'); } provider.seedSessions([metadata]); - fireSessionAdded(connection, 'removed-before-create', { title: 'main' }); + fireSessionAdded(connection, 'removed-before-create', { session: AgentSession.uri('ahp-session', 'removed-before-create'), title: 'main' }); assert.deepStrictEqual({ title: provider.getCachedSession('removed-before-create')?.title.get(), @@ -4833,7 +5143,8 @@ suite('CloudSandboxSessionsProvider provisional sessions', () => { connection.addSession(createSession('other-1', { summary: 'Someone else' })); const provider = createSandboxProvider(disposables, connection, { isWebPlatform: false, omitHostFromWorkspaceLabel: true }); provider.seedProvisionalSession({ - session: AgentSession.uri('copilotcli', 'provisional-1'), + session: AgentSession.uri('ahp-session', 'provisional-1'), + provider: 'copilotcli', startTime: 0, modifiedTime: 0, summary: 'Just provisioned', @@ -4844,7 +5155,7 @@ suite('CloudSandboxSessionsProvider provisional sessions', () => { const survivedUnknown = provider.getSessions().map((s: ISession) => AgentSession.id(s.resource)).sort(); // Once the host knows it, it reconciles like any other session. - connection.addSession(createSession('provisional-1', { summary: 'Just provisioned' })); + connection.addSession({ ...createSession('provisional-1', { summary: 'Just provisioned' }), session: AgentSession.uri('ahp-session', 'provisional-1'), provider: 'copilotcli' }); await refreshViaTurnComplete(connection, 'other-1'); const afterHostKnows = provider.getSessions().map((s: ISession) => AgentSession.id(s.resource)).sort(); @@ -4858,7 +5169,8 @@ suite('CloudSandboxSessionsProvider provisional sessions', () => { connection.addSession(createSession('other-1', { summary: 'Someone else' })); const provider = createSandboxProvider(disposables, connection, { isWebPlatform: false, omitHostFromWorkspaceLabel: true }); provider.seedProvisionalSession({ - session: AgentSession.uri('copilotcli', 'never-listed'), + session: AgentSession.uri('ahp-session', 'never-listed'), + provider: 'copilotcli', startTime: 0, modifiedTime: 0, summary: 'Never materialized', @@ -4893,7 +5205,8 @@ suite('CloudSandboxSessionsProvider provisional sessions', () => { // the sandbox is awake. const provider = createSandboxProvider(disposables, connection, { noConnection: true, isWebPlatform: false, omitHostFromWorkspaceLabel: true }); provider.seedProvisionalSession({ - session: AgentSession.uri('copilotcli', 'slow-wake'), + session: AgentSession.uri('ahp-session', 'slow-wake'), + provider: 'copilotcli', startTime: 0, modifiedTime: 0, summary: 'Slow to wake', @@ -4913,7 +5226,8 @@ suite('CloudSandboxSessionsProvider provisional sessions', () => { disposables.add(provider.onDidChangeSessions(e => announced.push(e.added.map(s => s.sessionId)))); provider.seedProvisionalSession({ - session: AgentSession.uri('copilotcli', 'withheld-1'), + session: AgentSession.uri('ahp-session', 'withheld-1'), + provider: 'copilotcli', startTime: 0, modifiedTime: 0, summary: 'Withheld Session', @@ -4943,7 +5257,8 @@ suite('CloudSandboxSessionsProvider provisional sessions', () => { test('publishing with announce:false lists the session without firing its own event', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const provider = createSandboxProvider(disposables, new MockAgentConnection(), { noConnection: true, isWebPlatform: false, omitHostFromWorkspaceLabel: true }); provider.seedProvisionalSession({ - session: AgentSession.uri('copilotcli', 'withheld-2'), + session: AgentSession.uri('ahp-session', 'withheld-2'), + provider: 'copilotcli', startTime: 0, modifiedTime: 0, summary: 'Withheld Session', diff --git a/src/vs/sessions/contrib/remoteSessions/browser/remoteSessionSource.ts b/src/vs/sessions/contrib/remoteSessions/browser/remoteSessionSource.ts index e4262fa88b622c..23ab0c0af3f09c 100644 --- a/src/vs/sessions/contrib/remoteSessions/browser/remoteSessionSource.ts +++ b/src/vs/sessions/contrib/remoteSessions/browser/remoteSessionSource.ts @@ -14,7 +14,7 @@ import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { IAgentHostConnectionsService, IAgentHostSessionIdentity, IAgentHostSessionResolution } from '../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { IRemoteSessionOrigin, readRemoteSessionDepth, readRemoteSessionOrigin } from '../../../../platform/agentHost/common/meta/agentRemoteSessionMeta.js'; -import { AGENT_HOST_SESSION_LINK_SCHEME, parseOpenSessionLinkChatId, parseOpenSessionLinkUri } from '../../../../platform/agentHost/common/openSessionLink.js'; +import { AGENT_HOST_SESSION_LINK_SCHEME, parseOpenSessionLinkChatId, parseOpenSessionLinkConnectionAuthority, parseOpenSessionLinkUri } from '../../../../platform/agentHost/common/openSessionLink.js'; import { IAgentSubscription } from '../../../../platform/agentHost/common/state/agentSubscription.js'; import { DEFAULT_CHAT_ID, getSessionChatResource, parseChatUri, StateComponents } from '../../../../platform/agentHost/common/state/sessionState.js'; import { isAgentHostSessionResource } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; @@ -46,14 +46,20 @@ export function resolveRemoteSessionReference(reference: string, connectionsServ const chatId = parseOpenSessionLinkChatId(resource); const parameters = new URLSearchParams(resource.query); if (!session || resource.fragment || parameters.getAll('chat').length > 1 - || [...parameters.keys()].some(key => key !== 'chat' && key !== 'turn') + || [...parameters.keys()].some(key => key !== 'chat' && key !== 'turn' && key !== 'session' && key !== 'connectionAuthority') || (parameters.has('chat') && !chatId && parameters.get('chat') !== DEFAULT_CHAT_ID)) { throw new Error(localize('remoteMessage.invalidLink', "The remote session link is invalid.")); } - resource = session.with({ fragment: chatId ?? '' }); + const authority = parseOpenSessionLinkConnectionAuthority(resource); + const qualified = authority ? connectionsService.findSessionResource(session, authority) : session; + if (!qualified) { + throw new Error(localize('remoteMessage.unqualifiedReference', "Use an exact host-qualified session or chat reference returned by the remote session tools.")); + } + resource = qualified.with({ fragment: chatId ?? '' }); } const identity = connectionsService.resolveSessionResourceIdentity(resource.with({ fragment: '' })); - if (resource.authority || resource.query || !resource.path.startsWith('/') || resource.path.length === 1 || !identity) { + if (!resource.path.startsWith('/') || resource.path.length === 1 || !identity + || ((resource.authority || resource.query) && !identity.backendSessionIsAdvertised)) { throw new Error(localize('remoteMessage.unqualifiedReference', "Use an exact host-qualified session or chat reference returned by the remote session tools.")); } return { resource: resource.fragment === DEFAULT_CHAT_ID ? resource.with({ fragment: '' }) : resource, identity }; diff --git a/src/vs/sessions/contrib/remoteSessions/test/browser/remoteSessionTools.test.ts b/src/vs/sessions/contrib/remoteSessions/test/browser/remoteSessionTools.test.ts index 21f311c363412a..45920a036daa38 100644 --- a/src/vs/sessions/contrib/remoteSessions/test/browser/remoteSessionTools.test.ts +++ b/src/vs/sessions/contrib/remoteSessions/test/browser/remoteSessionTools.test.ts @@ -6,16 +6,38 @@ import assert from 'assert'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { URI } from '../../../../../base/common/uri.js'; +import { isEqual } from '../../../../../base/common/resources.js'; +import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { buildOpenSessionLinkUri } from '../../../../../platform/agentHost/common/openSessionLink.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { RemoteAgentHostsEnabledSettingId } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { IToolInvocation } from '../../../../../workbench/contrib/chat/common/tools/languageModelToolsService.js'; import { CreateRemoteSessionTool, ListAgentHostsTool } from '../../browser/remoteSessionTools.js'; +import { resolveRemoteSessionReference } from '../../browser/remoteSessionSource.js'; import { ICreatedRemoteSession, ICreateRemoteSessionOptions, IRemoteSessionHost, IRemoteSessionService, RemoteSessionToolsEnabledSettingId } from '../../common/remoteSessions.js'; suite('RemoteSessionTools', () => { ensureNoDisposablesAreLeakedInTestSuite(); + + test('opaque scoped references require exact advertised identity instead of a reconstructed neighbor', () => { + const backend = URI.parse('session-store://tenant/root?revision=2'); + const frontend = backend.with({ scheme: 'remote-other-host-claude' }); + const connections = new class extends mock() { + override findSessionResource(resource: URI, authority?: string): URI | undefined { + return authority === 'other-host' && isEqual(resource, backend) ? frontend : undefined; + } + override resolveSessionResourceIdentity(resource: URI) { + return isEqual(resource, frontend) ? { backendSession: backend, backendSessionIsAdvertised: true as const, connectionAuthority: 'other-host' } + : { backendSession: resource.with({ scheme: 'claude' }), connectionAuthority: 'other-host' }; + } + }(); + const resolved = resolveRemoteSessionReference(buildOpenSessionLinkUri(backend, 'peer', undefined, 'other-host'), connections); + assert.deepStrictEqual([resolved.resource.toString(), resolved.identity.backendSession.toString()], [frontend.with({ fragment: 'peer' }).toString(), backend.toString()]); + assert.throws(() => resolveRemoteSessionReference(frontend.with({ query: 'revision=3' }).toString(), connections), /host-qualified/); + }); + const source = URI.parse('agent-host-copilot:/source#peer'); const progress = { report: () => { } }; const result: ICreatedRemoteSession = { diff --git a/src/vs/sessions/contrib/sessions/browser/automationsNewBadge.ts b/src/vs/sessions/contrib/sessions/browser/automationsNewBadge.ts deleted file mode 100644 index 0ae5c4f85939f8..00000000000000 --- a/src/vs/sessions/contrib/sessions/browser/automationsNewBadge.ts +++ /dev/null @@ -1,199 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Disposable, 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'; -import { ILogService } from '../../../../platform/log/common/log.js'; -import { observableMemento, type ObservableMemento } from '../../../../platform/observable/common/observableMemento.js'; -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'; -export const AUTOMATIONS_NEW_BADGE_STYLE_SETTING = 'sessions.automations.newBadgeStyle'; -export const AUTOMATIONS_NEW_BADGE_STYLE_TREATMENT = 'agentSessionsAutomationsNewBadgeStyle'; - -export type AutomationsNewBadgeStyle = 'accent' | 'soft' | 'outline' | 'unread'; - -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, - toStorage: value => String(value), - fromStorage: value => value === 'true', -}); - -/** Owns the first-use state for the Automations shortcut badge. */ -export class AutomationsNewBadgeState extends Disposable { - - private readonly seen: ObservableMemento; - private readonly resolvedStyle = observableValue(this, undefined); - 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 => { - 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( - @IAutomationService private readonly automationService: IAutomationService, - @ICustomViewService private readonly customViewService: ICustomViewService, - @IStorageService private readonly storageService: IStorageService, - @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.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(); - this._register(this.configurationService.onDidChangeConfiguration(event => { - if (event.affectsConfiguration(AUTOMATIONS_NEW_BADGE_STYLE_SETTING)) { - void this.updateStyle().catch(onUnexpectedError); - } - })); - this._register(this.assignmentService.onDidRefetchAssignments(() => { - void this.updateStyle().catch(onUnexpectedError); - })); - } - return this.initializationPromise; - } - - 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); - await this.updateStyle(); - } - - private async doInitialize(): Promise { - 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.canResolveStyle()) { - return; - } - - const request = ++this.styleRequest; - const inspection = this.configurationService.inspect(AUTOMATIONS_NEW_BADGE_STYLE_SETTING); - let value: string | undefined; - if (isConfigured(inspection)) { - value = inspection.value; - } else { - try { - value = await this.assignmentService.getTreatment(AUTOMATIONS_NEW_BADGE_STYLE_TREATMENT); - } catch (error) { - this.logService.warn(`[AutomationsNewBadgeState] Failed to resolve badge style treatment; using '${DEFAULT_AUTOMATIONS_NEW_BADGE_STYLE}'.`, error); - } - } - 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; - } - if (value === 'accent' || value === 'soft' || value === 'unread') { - return value; - } - this.logService.warn(`[AutomationsNewBadgeState] Unsupported badge style treatment '${value}'; using '${DEFAULT_AUTOMATIONS_NEW_BADGE_STYLE}'.`); - return DEFAULT_AUTOMATIONS_NEW_BADGE_STYLE; - } - - 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/sessionsList.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css index 13a254bbee63a5..064d99de4cd4d8 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css @@ -1166,25 +1166,6 @@ margin-right: 4px; } - .session-section-new-badge { - display: none; - align-items: center; - flex-shrink: 0; - height: var(--vscode-spacing-size160); - margin-left: var(--vscode-spacing-size40); - padding: 0 var(--vscode-spacing-size40); - box-sizing: border-box; - border: var(--vscode-strokeThickness) solid transparent; - border-radius: var(--vscode-cornerRadius-circle); - color: var(--vscode-foreground); - font-size: var(--vscode-fontSize-label3); - font-weight: var(--vscode-fontWeight-semiBold); - letter-spacing: 0.04em; - text-transform: uppercase; - white-space: nowrap; - pointer-events: none; - } - .session-section-keybinding { display: none; flex-shrink: 0; @@ -1199,20 +1180,6 @@ color: var(--vscode-foreground); } - .session-section-new-badge-accent { - border-color: var(--vscode-activityBarBadge-background); - background-color: var(--vscode-activityBarBadge-background); - color: var(--vscode-activityBarBadge-foreground); - } - - .session-section-new-badge-soft { - background-color: color-mix(in srgb, var(--vscode-foreground) 12%, transparent); - } - - .session-section-new-badge-outline { - border-color: var(--vscode-descriptionForeground); - } - .session-section-toolbar { flex-shrink: 0; margin-left: 4px; @@ -1239,11 +1206,6 @@ } } -.hc-black .session-section-new-badge, -.hc-light .session-section-new-badge { - border-color: var(--vscode-contrastBorder); -} - .monaco-list-row:is(:hover, .hovered) .session-section .session-section-toolbar, .monaco-list-row.focused .session-section .session-section-toolbar, .monaco-list-row .session-section.dropdown-active .session-section-toolbar { diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsListNotice.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsListNotice.css new file mode 100644 index 00000000000000..c141749cde0fe3 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsListNotice.css @@ -0,0 +1,42 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.agent-sessions-list-notice { + display: flex; + flex-shrink: 0; + flex-direction: column; + gap: var(--vscode-spacing-size80); + margin: var(--vscode-spacing-size80) var(--vscode-spacing-size100); + padding: var(--vscode-spacing-size100); + border: var(--vscode-strokeThickness) solid var(--vscode-sideBarSectionHeader-border, transparent); + border-radius: var(--vscode-cornerRadius-medium); + background-color: var(--vscode-sideBarSectionHeader-background, var(--vscode-sideBar-background)); +} + +.agent-sessions-list-notice-content { + display: flex; + align-items: flex-start; + gap: var(--vscode-spacing-size80); + min-width: 0; +} + +.agent-sessions-list-notice-description { + flex: 1; + min-width: 0; + line-height: 18px; + color: var(--vscode-foreground); +} + +.agent-sessions-list-notice .agent-sessions-list-notice-dismiss { + flex-shrink: 0; + width: auto; + padding: var(--vscode-spacing-size20); +} + +.agent-sessions-list-notice-actions { + display: flex; + flex-wrap: wrap; + gap: var(--vscode-spacing-size40); +} diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css index ff73c3ce6ab31c..c2e6f53172cb93 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css @@ -76,14 +76,6 @@ overflow: hidden; } -.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-session { - flex: 0 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - .command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-separator { flex-shrink: 0; } diff --git a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts index 10deb118377647..f091ccea788b0d 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts @@ -21,7 +21,6 @@ import './views/sessionsViewActions.js'; import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; import { SESSIONS_LIST_SHOW_ARCHIVED_BY_DEFAULT_SETTING, SESSIONS_LIST_SHOW_EMPTY_DEFAULT_GROUPS_SETTING, SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING } from './views/sessionsList.js'; -import { AUTOMATIONS_NEW_BADGE_STYLE_SETTING, AUTOMATIONS_NEW_BADGE_STYLE_TREATMENT } from './automationsNewBadge.js'; import { SessionsMouseNavigationContribution } from './sessionsMouseNavigation.js'; import './sessionDetailsAction.js'; import { SESSIONS_MARK_AS_DONE_CONFETTI_SETTING } from '../../../../platform/chat/common/sessionArchiveActions.js'; @@ -114,19 +113,6 @@ Registry.as(ConfigurationExtensions.Configuration).regis experiment: { mode: 'auto' } // https://github.com/microsoft/vscode/issues/335801 }, - [AUTOMATIONS_NEW_BADGE_STYLE_SETTING]: { - type: 'string', - enum: ['accent', 'soft', 'outline', 'unread'], - default: 'outline', - scope: ConfigurationScope.APPLICATION, - included: false, - tags: ['experimental'], - experiment: { - mode: 'auto', - name: AUTOMATIONS_NEW_BADGE_STYLE_TREATMENT, - }, - description: localize('sessions.automations.newBadgeStyle', "Controls the visual style of the Automations first-use badge."), - }, [NEW_SESSION_BUTTON_STYLE_SETTING]: { type: 'string', enum: ['default', 'lightweight', 'lightweightWithKeybindingBackground'], diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts index 943a162a8c2e7a..90b41a2fc9ea49 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts @@ -39,10 +39,6 @@ import { BlockedSessionsIndicatorModel, RequiresInputKind } from './blockedSessi import { getSessionWorkspaceDisplayInfo, ISessionWorkspaceDisplayInfo } from '../../../browser/sessionWorkspace.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { IBrowserWorkbenchEnvironmentService } from '../../../../workbench/services/environment/browser/environmentService.js'; -import { getUntitledSessionTitle } from '../../../services/sessions/common/session.js'; -import { SESSIONS_CHAT_TABS_DEFAULT, SESSIONS_CHAT_TABS_SETTING, SessionsChatTabsMode } from '../../../common/sessionConfig.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { observableConfigValue } from '../../../../platform/observable/common/platformObservableUtils.js'; /** * Internal command behind the blocked-sessions dropdown header's "Show All @@ -127,11 +123,11 @@ const BLOCKED_DROPDOWN_MIN_WIDTH = 550; const BLOCKED_DROPDOWN_MAX_WIDTH_RATIO = 0.9; /** - * Sessions Title Bar Widget - renders the active chat session + * Sessions Title Bar Widget - renders the active chat's workspace context * in the command center of the agent sessions workbench. * - * Shows the current chat session as a clickable pill with its workspace icon - * and folder name when available. + * Shows a clickable pill with the workspace icon and folder name when available, + * without the session or chat title. * * When at least one session is blocked (needs input or has failing CI checks), * the widget instead adopts an orange "N sessions require input" state and reveals those sessions as a @@ -167,8 +163,6 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { private _isRendering = false; private _workspaceInfo: ISessionWorkspaceDisplayInfo | undefined; private _isQuickChat = false; - private _activeSessionTitle: string | undefined; - private _activeChatTitle: string | undefined; private readonly _sessionTitle: string | undefined; /** The currently open blocked-sessions dropdown, if any. */ @@ -197,7 +191,6 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { @IQuickInputService private readonly quickInputService: IQuickInputService, @IHoverService private readonly hoverService: IHoverService, @IBrowserWorkbenchEnvironmentService environmentService: IBrowserWorkbenchEnvironmentService, - @IConfigurationService configurationService: IConfigurationService, ) { super(undefined, action, options); @@ -212,20 +205,11 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { this._render(); })); - const chatTabsMode = observableConfigValue(SESSIONS_CHAT_TABS_SETTING, SESSIONS_CHAT_TABS_DEFAULT, configurationService); - - // Re-render when the active session's title, presentation, workspace, or quick-chat kind changes + // Re-render when the active session's workspace or quick-chat kind changes this._register(autorun(reader => { const sessionData = this.sessionsService.activeSession.read(reader); this._workspaceInfo = getSessionWorkspaceDisplayInfo(sessionData, reader); this._isQuickChat = sessionData?.isQuickChat?.read(reader) ?? false; - const showSessionTitle = !!sessionData && chatTabsMode.read(reader) === SessionsChatTabsMode.Single; - this._activeSessionTitle = showSessionTitle - ? sessionData?.title.read(reader) || getUntitledSessionTitle(this._isQuickChat) - : undefined; - this._activeChatTitle = showSessionTitle - ? sessionData?.activeChat.read(reader).title.read(reader) || getUntitledSessionTitle(true) - : undefined; this._lastRenderState = undefined; this._render(); })); @@ -370,14 +354,11 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { */ private _renderActiveSession(): void { const container = this._container!; - const { sessionTitle, contextTitle } = this._getCommandCenterTitles(); + const contextTitle = this._getCommandCenterTitle(); const workspaceInfo = this._workspaceInfo; - const accessibleTitle = sessionTitle && contextTitle - ? localize('agentSessionsSessionWithContextAccessible', "{0}, {1}", sessionTitle, contextTitle) - : sessionTitle ?? contextTitle; - const accessibleTitleWithBranch = accessibleTitle && workspaceInfo?.branch - ? localize('agentSessionsSessionWithBranchAccessible', "{0}, branch {1}", accessibleTitle, workspaceInfo.branch) - : accessibleTitle; + const accessibleTitleWithBranch = contextTitle && workspaceInfo?.branch + ? localize('agentSessionsSessionWithBranchAccessible', "{0}, branch {1}", contextTitle, workspaceInfo.branch) + : contextTitle; container.setAttribute('aria-label', accessibleTitleWithBranch ? localize('agentSessionsShowSessionsWithTitle', "Show Sessions: {0}", accessibleTitleWithBranch) : localize('agentSessionsShowSessions', "Show Sessions")); @@ -388,19 +369,6 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { // Center group: workspace icon and name const centerGroup = $('div.agent-sessions-titlebar-center'); - if (sessionTitle) { - const sessionTitleEl = $('div.agent-sessions-titlebar-session'); - sessionTitleEl.textContent = sessionTitle; - centerGroup.appendChild(sessionTitleEl); - this._dynamicDisposables.add(this.hoverService.setupDelayedHover(sessionTitleEl, { content: sessionTitle })); - } - - if (sessionTitle && contextTitle) { - const separatorEl = $('span.agent-sessions-titlebar-separator', { 'aria-hidden': 'true' }); - separatorEl.textContent = '·'; - centerGroup.appendChild(separatorEl); - } - if (contextTitle) { const workspaceGroup = $('div.agent-sessions-titlebar-workspace-group'); if (workspaceInfo) { @@ -460,20 +428,7 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { } private _getCommandCenterTitle(): string | undefined { - const { sessionTitle, contextTitle } = this._getCommandCenterTitles(); - if (sessionTitle && contextTitle) { - return localize('agentSessionsSessionWithContext', "{0} · {1}", sessionTitle, contextTitle); - } - return sessionTitle ?? contextTitle; - } - - private _getCommandCenterTitles(): { sessionTitle: string | undefined; contextTitle: string | undefined } { - const contextTitle = this._sessionTitle ?? this._workspaceInfo?.label ?? (this._isQuickChat ? localize('noWorkspace', "No workspace") : undefined); - const sessionTitle = this._activeSessionTitle === this._activeChatTitle ? undefined : this._activeSessionTitle; - if (!sessionTitle || sessionTitle === contextTitle) { - return { sessionTitle: undefined, contextTitle: sessionTitle ?? contextTitle }; - } - return { sessionTitle, contextTitle }; + return this._sessionTitle ?? this._workspaceInfo?.label ?? (this._isQuickChat ? localize('noWorkspace', "No workspace") : undefined); } /** diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index 3a62d0f8321ba5..59a8f6d6152655 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -111,7 +111,6 @@ import { IAutomationService } from '../../../../../workbench/contrib/chat/common import { IEditorService } from '../../../../../workbench/services/editor/common/editorService.js'; import { ICustomViewService } from '../../../../services/customView/browser/customViewService.js'; import { AUTOMATIONS_CUSTOM_VIEW_ID } from '../automationsConstants.js'; -import { AutomationsNewBadgeState, type AutomationsNewBadgeStyle } from '../automationsNewBadge.js'; import { OPEN_AI_CUSTOMIZATIONS_COMMAND_ID } from '../customizationsConstants.js'; import { NEW_SESSION_ACTION_ID } from '../../../chat/common/constants.js'; import { Menus } from '../../../../browser/menus.js'; @@ -2115,7 +2114,6 @@ interface ISessionSectionTemplate extends ISessionHeaderTemplate { readonly label: HTMLElement; readonly count: HTMLElement; readonly countLabel: HTMLElement; - readonly newBadge: HTMLElement; readonly keybindingHint: HTMLElement; readonly keybindingLabel: KeybindingLabel; readonly chevron: HTMLElement; @@ -2175,7 +2173,6 @@ export class SessionSectionRenderer implements ITreeRenderer, - private readonly automationNewBadgePresentation: IObservable, private readonly uriIdentityService: IUriIdentityService, private readonly customViewService: ICustomViewService, private readonly menuService: IMenuService, @@ -2204,9 +2201,6 @@ export class SessionSectionRenderer implements ITreeRenderer, _index: number, template: ISessionSectionTemplate): void { @@ -2290,12 +2284,6 @@ export class SessionSectionRenderer implements ITreeRenderer { const activeCustomView = this.customViewService.activeCustomView.read(reader); template.container.classList.toggle('active', activeCustomView?.id === AUTOMATIONS_CUSTOM_VIEW_ID && !this.customizationsActive.read(reader)); - const badgeStyle = this.automationNewBadgePresentation.read(reader); - template.newBadge.style.display = badgeStyle && badgeStyle !== 'unread' ? 'inline-flex' : 'none'; - template.newBadge.classList.toggle('session-section-new-badge-accent', badgeStyle === 'accent'); - template.newBadge.classList.toggle('session-section-new-badge-soft', badgeStyle === 'soft'); - template.newBadge.classList.toggle('session-section-new-badge-outline', badgeStyle === 'outline'); })); const statusIcon = template.elementDisposables.add(this.instantiationService.createInstance(SessionStatusIcon, template.icon)); template.elementDisposables.add(autorun(reader => { const automationStatus = this.automationStatus.read(reader); - const badgeStyle = this.automationNewBadgePresentation.read(reader); if (automationStatus === SessionStatus.NeedsInput) { template.icon.className = 'session-section-icon'; statusIcon.setStatus(SessionStatus.NeedsInput, true, false); @@ -2354,9 +2336,6 @@ export class SessionSectionRenderer implements ITreeRenderer string | undefined; readonly isRenderedInExternalSection?: (session: ISession) => boolean; readonly includeQuickChatInAriaLabel?: boolean; - readonly automationNewBadgeVisible?: IObservable; readonly customizationsCount?: IObservable; readonly customizationMigrationsAvailable?: IObservable; readonly newSessionKeybindingAriaLabel?: IObservable; @@ -2849,9 +2827,7 @@ class SessionsAccessibilityProvider { label = localize('automationsUnreadRunAria', "{0}, unread run", element.label); break; } - return this.options?.automationNewBadgeVisible?.read(reader) - ? localize('automationsNewFeatureAria', "{0}, new feature", label) - : label; + return label; }); } if (element.id === CUSTOMIZATIONS_SECTION_ID) { @@ -3517,7 +3493,6 @@ export class SessionsList extends Disposable implements ISessionsList { private readonly chatRowHeightReconcile = this._register(new MutableDisposable()); private readonly automationSessions = observableValue(this, []); private readonly collapsedSessionIds = observableValue>(this, new Set()); - private readonly automationsNewBadgeState: AutomationsNewBadgeState; /** * Session IDs whose hierarchy indent/connector guides should be visible: * the union of the currently-hovered session (if any) and every session @@ -3592,6 +3567,8 @@ export class SessionsList extends Disposable implements ISessionsList { private readonly _onDidUpdate = this._register(new Emitter()); readonly onDidUpdate: Event = this._onDidUpdate.event; + private readonly _onDidOpenSession = this._register(new Emitter()); + readonly onDidOpenSession = this._onDidOpenSession.event; private readonly _onDidChangeFindOpenState = this._register(new Emitter()); readonly onDidChangeFindOpenState: Event = this._onDidChangeFindOpenState.event; @@ -3602,13 +3579,6 @@ export class SessionsList extends Disposable implements ISessionsList { return (this.options.compact?.() ?? false) && !IsPhoneLayoutContext.getValue(this.contextKeyService); } - async resetAutomationsNewBadge(): Promise { - if (this.customViewService.activeCustomView.get()?.id === AUTOMATIONS_CUSTOM_VIEW_ID) { - this.customViewService.hideCustomView(); - } - await this.automationsNewBadgeState.reset(); - } - constructor( container: HTMLElement, private readonly options: ISessionsListControlOptions, @@ -3642,8 +3612,6 @@ export class SessionsList extends Disposable implements ISessionsList { @IAccessibilityService accessibilityService: IAccessibilityService, ) { super(); - this.automationsNewBadgeState = this._register(instantiationService.createInstance(AutomationsNewBadgeState)); - this.filters = this._register(instantiationService.createInstance(SessionsListFilters)); for (const key of [SessionsList.EXCLUDED_TYPES_KEY, SessionsList.EXCLUDED_STATUSES_KEY, SessionsList.EXCLUDE_READ_KEY, SessionsList.SHOW_EMPTY_GROUPS_KEY]) { this.storageService.remove(key, StorageScope.PROFILE); @@ -3809,7 +3777,6 @@ export class SessionsList extends Disposable implements ISessionsList { contextKeyService, this.automationService, this.automationSessions, - this.automationsNewBadgeState.presentation, this.uriIdentityService, this.customViewService, this.menuService, @@ -3861,7 +3828,6 @@ export class SessionsList extends Disposable implements ISessionsList { grouping: this.options.grouping, isPinned: session => this.isSessionPinned(session), widgetAriaLabel: localize('sessionsNavigation', "Sessions Navigation"), - automationNewBadgeVisible: this.automationsNewBadgeState.showNewBadge, customizationsCount, customizationMigrationsAvailable, newSessionKeybindingAriaLabel, @@ -4615,7 +4581,6 @@ export class SessionsList extends Disposable implements ISessionsList { navigationSections.push({ id: NEW_SESSION_SECTION_ID, label: localize('newSession', "New Session"), sessions: [] }); } if (this.contextKeyService.getContextKeyValue(ChatAutomationsEnabledContext.key)) { - void this.automationsNewBadgeState.initialize().catch(onUnexpectedError); navigationSections.push({ id: AUTOMATIONS_SECTION_ID, label: localize('automations', "Automations"), sessions: [] }); } if (showNavigationShortcuts) { @@ -5225,9 +5190,10 @@ export class SessionsList extends Disposable implements ISessionsList { private invokeOpenRequest(request: IListOpenRequest): void { request.started = true; const invocation = ++request.invocation; - const finish = () => { + const finish = (opened: boolean) => { if (this.pendingOpenRequest === request && request.invocation === invocation) { this.pendingOpenRequest = undefined; + if (opened) { this._onDidOpenSession.fire(request.chat?.resource ?? request.session.resource); } } }; @@ -5241,16 +5207,16 @@ export class SessionsList extends Disposable implements ISessionsList { open = this.options.onSessionOpen(request.session.resource, request.preserveFocus, request.sideBySide); } } catch (error) { - finish(); + finish(false); throw error; } if (!open) { - finish(); + finish(true); return; } - open.then(finish, error => { - finish(); + open.then(() => finish(true), error => { + finish(false); onUnexpectedError(error); }); } diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsListNotice.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsListNotice.ts new file mode 100644 index 00000000000000..8da7b9977d2d6d --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsListNotice.ts @@ -0,0 +1,78 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as DOM from '../../../../../base/browser/dom.js'; +import { Button } from '../../../../../base/browser/ui/button/button.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Event } from '../../../../../base/common/event.js'; +import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { localize } from '../../../../../nls.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; +import '../media/sessionsListNotice.css'; + +export interface ISessionsListNoticeHost { + readonly container: HTMLElement; + readonly onDidChangeVisibility: Event; + readonly onDidOpenSession: Event; + revealSession(resource: URI): IDisposable & { readonly targetId: string; open(token: CancellationToken): Promise }; + isVisible(): boolean; + focusSessionsList(): void; + announce(message: string): void; +} + +type NoticeFactory = (instantiation: IInstantiationService, host: ISessionsListNoticeHost) => IDisposable; +const factories: NoticeFactory[] = []; + +/** Provider-owned notices contribute through this slot without provider imports in the list. */ +export function registerSessionsListNotice(factory: NoticeFactory): void { + factories.push(factory); +} + +export function createSessionsListNotices(instantiation: IInstantiationService, host: ISessionsListNoticeHost): IDisposable[] { + return factories.map(factory => factory(instantiation, host)); +} + +export interface ISessionsListNoticeOptions { + readonly description: string; + readonly label: string; + readonly disableLabel: string; + readonly dismiss: () => void; + readonly run: () => void; + readonly disable: () => void; + readonly focusSessionsList: () => void; +} + +/** A quiet, keyboard-accessible notice below the Sessions tree. */ +export class SessionsListNotice extends Disposable { + readonly domNode: HTMLElement; + constructor(options: ISessionsListNoticeOptions) { + super(); + this.domNode = DOM.$('.agent-sessions-list-notice', { role: 'region', 'aria-label': options.label }); + const content = DOM.append(this.domNode, DOM.$('.agent-sessions-list-notice-content')); + DOM.append(content, DOM.$('.agent-sessions-list-notice-description')).textContent = options.description; + const dismissLabel = localize('sessionsListNotice.dismiss', "Dismiss Suggestion"); + const dismiss = this._register(new Button(content, { ...defaultButtonStyles, secondary: true, title: dismissLabel, ariaLabel: dismissLabel })); + dismiss.element.classList.add('agent-sessions-list-notice-dismiss'); + dismiss.icon = Codicon.close; + const close = () => { options.dismiss(); options.focusSessionsList(); }; + this._register(dismiss.onDidClick(close)); + this._register(dismiss.onDidEscape(close)); + const actions = DOM.append(this.domNode, DOM.$('.agent-sessions-list-notice-actions')); + const primary = this._register(new Button(actions, defaultButtonStyles)); + primary.label = options.label; + this._register(primary.onDidClick(options.run)); + this._register(primary.onDidEscape(close)); + const secondary = this._register(new Button(actions, { ...defaultButtonStyles, secondary: true })); + secondary.label = options.disableLabel; + this._register(secondary.onDidClick(() => { options.disable(); options.focusSessionsList(); })); + this._register(secondary.onDidEscape(close)); + this._register(DOM.addDisposableListener(this.domNode, DOM.EventType.KEY_DOWN, event => { + if (event.key === 'Escape') { DOM.EventHelper.stop(event, true); close(); } + })); + } +} diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts index 0e0e5c428b11a8..51b30b9dcd093a 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts @@ -60,6 +60,7 @@ import { SessionsListRearrangeExperimentState } from '../sessionsListRearrangeEx import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { IChatEntitlementService } from '../../../../../workbench/services/chat/common/chatEntitlementService.js'; import { CustomizationsNavigationState } from '../customizationsNavigationState.js'; +import { createSessionsListNotices } from './sessionsListNotice.js'; import { SessionStorageCleanupNotice } from './sessionStorageCleanupNotice.js'; import { SessionsListNotification } from './sessionsListNotification.js'; @@ -331,6 +332,28 @@ export class SessionsView extends ViewPane { })); const storageCleanupNotice = this._register(this.instantiationService.createInstance(SessionStorageCleanupNotice, () => sessionsControl.focus(), status)); sessionsContent.appendChild(storageCleanupNotice.domNode); + for (const notice of createSessionsListNotices(this.instantiationService, { + container: sessionsContent, + onDidChangeVisibility: this.onDidChangeBodyVisibility, + isVisible: () => this.isBodyVisible(), + focusSessionsList: () => sessionsControl.focus(), + onDidOpenSession: sessionsControl.onDidOpenSession, + revealSession: resource => { + const session = this.sessionsManagementService.getSession(resource); + if (!session) { throw new Error('Session is no longer available'); } + const reveal = sessionsControl.revealSessionForOnboarding(session); + return { + targetId: reveal.targetId, + open: async token => { + if (token.isCancellationRequested || !await this.sessionsService.canOpenSession(session) || token.isCancellationRequested) { return false; } + await this.sessionsService.openSession(session.resource, { forceMainChat: true, source: 'sessionsList' }); + return true; + }, + dispose: () => reveal.dispose(), + }; + }, + announce: status, + })) { this._register(notice); } this._register(this.onDidChangeBodyVisibility(visible => sessionsControl.setVisible(visible))); this.archiveNotification = this._register(this.instantiationService.createInstance(SessionsListNotification, sessionsContent, () => sessionsControl.focus())); diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts index e1fb7b1baa3926..f3829e9af0c895 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts @@ -14,12 +14,10 @@ import { hasKey } from '../../../../../base/common/types.js'; import { localize, localize2 } from '../../../../../nls.js'; import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; -import { Categories } from '../../../../../platform/action/common/actionCommonCategories.js'; import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js'; import { ConfigurationTarget, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; -import { IsDevelopmentContext } from '../../../../../platform/contextkey/common/contextkeys.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; @@ -49,7 +47,6 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase import { registerExternalSessionsFilterMenu } from '../../../../../workbench/contrib/chat/browser/agentSessions/externalSessionsFilterMenu.js'; import { ICustomViewService } from '../../../../services/customView/browser/customViewService.js'; import { IAutomationService } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; -import { ChatAutomationsEnabledContext } from '../../../../../workbench/contrib/chat/common/automations/automationsEnabled.js'; import { AUTOMATIONS_CUSTOM_VIEW_ID } from '../automationsConstants.js'; import { UNIFIED_WORKSPACE_PICKER_SETTING } from '../../../chat/common/constants.js'; import { INewSessionComposerService } from '../../../chat/browser/newSessionComposerService.js'; @@ -1713,23 +1710,6 @@ registerAction2(class ManageAutomationsAction extends Action2 { } }); -registerAction2(class ResetAutomationsNewBadgeAction extends Action2 { - constructor() { - super({ - id: 'sessions.developer.resetAutomationsNewBadge', - title: localize2('resetAutomationsNewBadge', "Reset Automations New Badge"), - category: Categories.Developer, - f1: true, - precondition: ContextKeyExpr.and(IsDevelopmentContext, IsSessionsWindowContext, ChatAutomationsEnabledContext), - }); - } - - override async run(accessor: ServicesAccessor): Promise { - const view = await accessor.get(IViewsService).openView(SessionsViewId, false); - await view?.sessionsControl?.resetAutomationsNewBadge(); - } -}); - const MARK_ALL_AUTOMATION_RUNS_READ_COMMAND_ID = 'sessionsView.markAllAutomationRunsRead'; registerAction2(class MarkAllAutomationRunsReadAction extends Action2 { diff --git a/src/vs/sessions/contrib/sessions/test/browser/automationsNewBadge.test.ts b/src/vs/sessions/contrib/sessions/test/browser/automationsNewBadge.test.ts deleted file mode 100644 index b6a1bfb5217357..00000000000000 --- a/src/vs/sessions/contrib/sessions/test/browser/automationsNewBadge.test.ts +++ /dev/null @@ -1,361 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { 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'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import type { IConfigurationChangeEvent } from '../../../../../platform/configuration/common/configuration.js'; -import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; -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 { 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'; - -suite('AutomationsNewBadgeState', () => { - const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - - class TestAssignmentService extends mock() { - readonly treatments: string[] = []; - override readonly onDidRefetchAssignments; - - constructor( - private readonly style: AutomationsNewBadgeStyle | undefined, - refetchAssignments: Emitter, - private readonly error?: Error, - ) { - super(); - this.onDidRefetchAssignments = refetchAssignments.event; - } - - override async getTreatment(name: string): Promise { - this.treatments.push(name); - if (this.error) { - throw this.error; - } - return this.style as T | undefined; - } - } - - function createState(options: { - readonly automations?: readonly IAutomationDescriptor[]; - 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; - } = {}) { - const storageService = disposables.add(new InMemoryStorageService()); - if (options.seen) { - storageService.store(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); - } - 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; - }; - const refetchAssignments = disposables.add(new Emitter()); - const assignmentService = new TestAssignmentService(options.style, refetchAssignments, options.treatmentError); - const configurationService = new TestConfigurationService(); - 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, - storageService, - assignmentService, - configurationService, - new NullLogService(), - sessionsWindowUsageService, - lifecycleService, - )); - return { - state, - storageService, - automations, - runs, - activeView, - assignmentService, - configurationService, - refetchAssignments, - catalogueState, - completeEventually: () => eventually.complete(), - }; - } - - test('stays hidden on the first Agents window open without reading the treatment', async () => { - const fixture = createState({ hadPriorWindowOpen: false, style: 'accent' }); - - await fixture.state.initialize(); - fixture.refetchAssignments.fire(); - await Promise.resolve(); - - 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 every supported style for eligible returning users', async () => { - const snapshots = []; - for (const style of ['accent', 'soft', 'outline', 'unread'] as const) { - const fixture = createState({ style }); - await fixture.state.initialize(); - snapshots.push({ - style: fixture.state.presentation.get(), - treatments: fixture.assignmentService.treatments, - }); - } - - assert.deepStrictEqual(snapshots, [ - { style: 'accent', treatments: [AUTOMATIONS_NEW_BADGE_STYLE_TREATMENT] }, - { style: 'soft', treatments: [AUTOMATIONS_NEW_BADGE_STYLE_TREATMENT] }, - { style: 'outline', treatments: [AUTOMATIONS_NEW_BADGE_STYLE_TREATMENT] }, - { style: 'unread', treatments: [AUTOMATIONS_NEW_BADGE_STYLE_TREATMENT] }, - ]); - }); - - 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({ - beforeEvidence, - afterEvidence: fixture.state.presentation.get(), - stored: fixture.storageService.get(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION), - }, { - 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(); - const initial = fixture.state.presentation.get(); - - await fixture.configurationService.setUserConfiguration(AUTOMATIONS_NEW_BADGE_STYLE_SETTING, 'unread'); - fixture.configurationService.onDidChangeConfigurationEmitter.fire(upcastPartial({ - affectsConfiguration: key => key === AUTOMATIONS_NEW_BADGE_STYLE_SETTING, - })); - - assert.deepStrictEqual({ - initial, - updated: fixture.state.presentation.get(), - treatments: fixture.assignmentService.treatments, - }, { - initial: 'soft', - updated: 'unread', - treatments: [], - }); - }); - - test('falls back to outline when treatment resolution fails', async () => { - const fixture = createState({ treatmentError: new Error('Unavailable') }); - - await fixture.state.initialize(); - - assert.deepStrictEqual({ - style: fixture.state.presentation.get(), - treatments: fixture.assignmentService.treatments, - }, { - style: 'outline', - treatments: [AUTOMATIONS_NEW_BADGE_STYLE_TREATMENT], - }); - }); - - test('force preview bypasses first-use and Automation evidence until activation', async () => { - const fixture = createState({ - hadPriorWindowOpen: false, - automations: [upcastPartial({ id: 'existing-automation' })], - style: 'unread', - }); - await fixture.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({ - preview, - afterActivation: { - showNewBadge: fixture.state.showNewBadge.get(), - stored: fixture.storageService.get(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION), - }, - }, { - preview: { - style: 'unread', - stored: undefined, - }, - afterActivation: { - showNewBadge: false, - stored: 'true', - }, - }); - }); - - test('honors persisted and restored seen state before the row renders', async () => { - const persisted = createState({ seen: true }); - const restored = createState({ - activeView: upcastPartial({ id: AUTOMATIONS_CUSTOM_VIEW_ID }), - }); - - await persisted.state.initialize(); - await restored.state.initialize(); - - assert.deepStrictEqual({ - persisted: { - showNewBadge: persisted.state.showNewBadge.get(), - }, - restored: { - showNewBadge: restored.state.showNewBadge.get(), - stored: restored.storageService.get(AUTOMATIONS_NEW_BADGE_SEEN_STORAGE_KEY, StorageScope.APPLICATION), - }, - }, { - persisted: { showNewBadge: false }, - restored: { showNewBadge: false, stored: 'true' }, - }); - }); -}); diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.fixture.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.fixture.ts index c458c2c168fc1e..bb07bb27cd94c9 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.fixture.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.fixture.ts @@ -10,7 +10,6 @@ import { ComponentFixtureContext, defineThemedFixtureGroup } from '../../../../. import { SESSIONS_LIST_GROUP_EXTERNAL_SESSIONS_SETTING } from '../../../../common/sessionConfig.js'; import { SessionStatus } from '../../../../services/sessions/common/session.js'; import { createSessionArchiveTour } from '../../../onboardingTours/browser/tours/sessionArchiveTour.js'; -import { AUTOMATIONS_NEW_BADGE_STYLE_SETTING } from '../../browser/automationsNewBadge.js'; import { SESSIONS_LIST_SHOW_UNREAD_IN_COLLAPSED_SECTIONS_SETTING, SessionsGrouping } from '../../browser/views/sessionsList.js'; import { defineSessionsListFixture, ISessionsListFixture, ISessionsListFixtureGroup, ISessionsListFixtureSession } from './sessionsListFixtureUtils.js'; @@ -357,6 +356,7 @@ export default defineThemedFixtureGroup({ path: 'sessions/' }, { view: { height: 380 }, interaction: { focused: { group: 'release' } }, }, { + deferPaint: true, labels: { kind: 'screenshot' }, additionalThemes: ['darkHighContrast'], expectedVisualDescriptions: ['The Release work group header has keyboard focus, shown with a focus outline, a chevron in place of the group icon, and its New Session and Archive All actions.'], @@ -716,66 +716,13 @@ export default defineThemedFixtureGroup({ path: 'sessions/' }, { //#region Header - SessionsList_AutomationsNewBadge: defineSessionsListFixture({ - sessions: [], - header: { automations: true }, - }, { - labels: { kind: 'screenshot', blocksCi: true }, - additionalThemes: ['darkHighContrast'], - expectedVisualDescriptions: ['The Sessions header has an outlined New button. Directly below it, the Automations row has a smaller right-aligned outlined NEW capsule that reads as a non-interactive feature badge rather than a second button.'], - }), - SessionsList_AutomationsNewBadge_Accent: defineSessionsListFixture({ - sessions: [], - header: { automations: true }, - settings: { [AUTOMATIONS_NEW_BADGE_STYLE_SETTING]: 'accent' }, - }, { - labels: { kind: 'screenshot', blocksCi: true }, - additionalThemes: ['darkHighContrast'], - expectedVisualDescriptions: ['The Automations row has a compact right-aligned NEW pill using the prominent activity badge colors, while the larger outlined New button remains visually distinct in the Sessions header.'], - }), - SessionsList_AutomationsNewBadge_Soft: defineSessionsListFixture({ - sessions: [], - header: { automations: true }, - settings: { [AUTOMATIONS_NEW_BADGE_STYLE_SETTING]: 'soft' }, - }, { - labels: { kind: 'screenshot', blocksCi: true }, - additionalThemes: ['darkHighContrast'], - expectedVisualDescriptions: ['The Automations row has a compact right-aligned NEW pill with a subtle neutral fill, while the larger outlined New button remains visually distinct in the Sessions header.'], - }), - SessionsList_AutomationsNewBadge_Unread: defineSessionsListFixture({ - sessions: [], - header: { automations: true }, - settings: { [AUTOMATIONS_NEW_BADGE_STYLE_SETTING]: 'unread' }, - }, { - labels: { kind: 'screenshot', blocksCi: true }, - additionalThemes: ['darkHighContrast'], - expectedVisualDescriptions: ['The Automations row uses the standard filled blue unread indicator in its leading icon slot to signal the new feature and does not show a trailing NEW capsule, while the Sessions header retains its outlined New button.'], - }), - SessionsList_AutomationsNewBadge_Narrow: defineSessionsListFixture({ - sessions: [], - view: { width: 170 }, - header: { automations: true }, - }, { - labels: { kind: 'screenshot', blocksCi: true }, - additionalThemes: ['darkHighContrast'], - expectedVisualDescriptions: ['At the 170px minimum sidebar width, the Automations label remains readable and the outlined NEW capsule stays right-aligned without changing the row height.'], - }), - SessionsList_AutomationsNewBadge_Running: defineSessionsListFixture({ - sessions: [], - header: { automations: true, automationRunStatus: 'running' }, - }, { - labels: { kind: 'screenshot', blocksCi: true }, - additionalThemes: ['darkHighContrast'], - expectedVisualDescriptions: ['The Automations row shows its running status icon and the outlined NEW capsule together without overlap or layout shift.'], - }), SessionsList_CustomizationsNavigationTreatment: defineSessionsListFixture({ sessions: [{ id: 'treatment', title: 'Validate the customizations experiment', workspace: 'vscode', minutesAgo: 5 }], header: { navigationShortcuts: true, customizationsCount: 8 }, - settings: { [AUTOMATIONS_NEW_BADGE_STYLE_SETTING]: 'outline' }, }, { labels: { kind: 'screenshot' }, additionalThemes: ['darkHighContrast'], - expectedVisualDescriptions: ['New Session, Automations, and Customizations appear as three inset navigation rows above the Sessions header. The hovered New Session row uses the same horizontal inset and rounded control-tier corners as the session row below. New Session has a plus icon and its platform keybinding aligned on the right, Automations has a compact right-aligned NEW capsule, and Customizations has no total-count badge.'], + expectedVisualDescriptions: ['New Session, Automations, and Customizations appear as three inset navigation rows above the Sessions header. The hovered New Session row uses the same horizontal inset and rounded control-tier corners as the session row below. New Session has a plus icon and its platform keybinding aligned on the right, Automations has a calendar icon, and Customizations has no total-count badge.'], afterRender: fixture => { const newSessionRow = fixture.container.querySelector('.session-section-new-session')?.closest('.monaco-list-row'); if (!newSessionRow) { 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 d0b7f58cd0f2b6..13aba9f29b3ad8 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import sinon from 'sinon'; -import { timeout } from '../../../../../base/common/async.js'; +import { DeferredPromise, timeout } from '../../../../../base/common/async.js'; import { IDelayedHoverOptions } from '../../../../../base/browser/ui/hover/hover.js'; import { HoverPosition } from '../../../../../base/browser/ui/hover/hoverWidget.js'; import { mainWindow } from '../../../../../base/browser/window.js'; @@ -86,7 +86,6 @@ import { SessionsArchiveActionsContribution } from '../../browser/views/sessions import { renderSessionsHeader } from '../../browser/views/sessionsView.js'; import { computePullRequestIcon, GitHubPullRequestState } from '../../../github/common/types.js'; import { AUTOMATIONS_CUSTOM_VIEW_ID } from '../../browser/automationsConstants.js'; -import { AUTOMATIONS_NEW_BADGE_STYLE_SETTING, type AutomationsNewBadgeStyle } from '../../browser/automationsNewBadge.js'; import { OPEN_SESSION_COMPARISON_COMMAND_ID } from '../../../sessionComparison/common/sessionComparison.js'; import { BlockedSessionReason, BlockedSessions } from '../../../blockedSessions/browser/blockedSessions.js'; import { NEW_SESSION_ACTION_ID } from '../../../chat/common/constants.js'; @@ -176,7 +175,6 @@ suite('Sessions - SessionsList', () => { contextKeyService, automationService, constObservable([]), - constObservable(undefined), new class extends mock() { override readonly extUri = new ExtUri(() => true); }, @@ -233,7 +231,6 @@ suite('Sessions - SessionsList', () => { contextKeyService, automationService, constObservable([]), - constObservable(undefined), new class extends mock() { override readonly extUri = new ExtUri(() => true); }, @@ -258,192 +255,16 @@ suite('Sessions - SessionsList', () => { assert.deepStrictEqual({ watchIcon: !!container.querySelector('.session-section-icon.codicon-watch'), spinnerParent: spinner?.parentElement?.className, + newBadge: !!container.querySelector('.session-section-new-badge'), trailingStatusIndicator: !!container.querySelector('.session-section-status-indicator'), }, { watchIcon: false, spinnerParent: 'session-section-icon', + newBadge: false, trailingStatusIndicator: false, }); }); - test('renders new badge presentations only on the Automations section when templates are recycled', () => { - const instantiationService = disposables.add(new TestInstantiationService()); - instantiationService.stubInstance(MenuWorkbenchToolBar, new class extends mock() { - override set context(_context: unknown) { } - override dispose(): void { } - }); - instantiationService.stub(IAccessibilityService, new class extends TestAccessibilityService { - override isMotionReduced(): boolean { return false; } - }()); - instantiationService.stub(ISessionsListModelService, new class extends mock() { - override getStatusIcon(_status: SessionStatus, isRead: boolean) { - return isRead ? Codicon.circleSmallFilled : Codicon.circleFilled; - } - }); - const contextKeyService = disposables.add(new ContextKeyService(new TestConfigurationService())); - const runs = observableValue(disposables, []); - const badgePresentation = observableValue(disposables, 'outline'); - const automationService = new class extends mock() { - override readonly runs = runs; - }; - const renderer = new SessionSectionRenderer( - true, - () => { }, - constObservable(true), - constObservable(new Set()), - noHeaderStatusTrigger, - instantiationService, - contextKeyService, - automationService, - constObservable([]), - badgePresentation, - new class extends mock() { - override readonly extUri = new ExtUri(() => true); - }, - new class extends mock() { - override readonly activeCustomView = constObservable(undefined); - }, - new class extends mock() { }, - noKeybindingService, - constObservable(false), - ); - const container = document.createElement('div'); - const template = renderer.renderTemplate(container); - disposables.add(template.disposables); - - renderer.renderElement(upcastPartial[0]>({ - element: { id: 'automations', label: 'Automations', sessions: [] }, - collapsible: false, - collapsed: false, - }), 0, template); - const getPresentationSnapshot = () => ({ - badgeText: template.newBadge.textContent, - badgeDisplay: template.newBadge.style.display, - badgeAriaHidden: template.newBadge.getAttribute('aria-hidden'), - hasOutlineBadge: template.newBadge.classList.contains('session-section-new-badge-outline'), - hasUnreadDot: !!container.querySelector('.session-section-icon > .codicon-circle-filled:not([data-icon-fading-out="1"])'), - hasSpinner: !!container.querySelector('.session-section-icon > .monaco-pixel-spinner:not([data-icon-fading-out="1"])'), - hasCalendar: template.icon.classList.contains('codicon-calendar'), - }); - const outline = getPresentationSnapshot(); - - badgePresentation.set('unread', undefined); - const unread = getPresentationSnapshot(); - - runs.set([upcastPartial({ status: 'running' })], undefined); - const running = getPresentationSnapshot(); - - runs.set([], undefined); - const unreadRestored = getPresentationSnapshot(); - - badgePresentation.set(undefined, undefined); - const dismissed = getPresentationSnapshot(); - - renderer.renderElement(upcastPartial[0]>({ - element: { id: 'workspace:test', label: 'Test', sessions: [] }, - collapsible: true, - collapsed: false, - }), 0, template); - - assert.deepStrictEqual({ - outline, - unread, - running, - unreadRestored, - dismissed, - recycledDisplay: template.newBadge.style.display, - recycledShortcutClass: template.container.classList.contains('session-section-shortcut'), - }, { - outline: { - badgeText: 'New', - badgeDisplay: 'inline-flex', - badgeAriaHidden: 'true', - hasOutlineBadge: true, - hasUnreadDot: false, - hasSpinner: false, - hasCalendar: true, - }, - unread: { - badgeText: 'New', - badgeDisplay: 'none', - badgeAriaHidden: 'true', - hasOutlineBadge: false, - hasUnreadDot: true, - hasSpinner: false, - hasCalendar: false, - }, - running: { - badgeText: 'New', - badgeDisplay: 'none', - badgeAriaHidden: 'true', - hasOutlineBadge: false, - hasUnreadDot: false, - hasSpinner: true, - hasCalendar: false, - }, - unreadRestored: { - badgeText: 'New', - badgeDisplay: 'none', - badgeAriaHidden: 'true', - hasOutlineBadge: false, - hasUnreadDot: true, - hasSpinner: false, - hasCalendar: false, - }, - dismissed: { - badgeText: 'New', - badgeDisplay: 'none', - badgeAriaHidden: 'true', - hasOutlineBadge: false, - hasUnreadDot: false, - hasSpinner: false, - hasCalendar: true, - }, - recycledDisplay: 'none', - recycledShortcutClass: false, - }); - }); - - 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); - void (instantiationService.get(IConfigurationService) as TestConfigurationService).setUserConfiguration(AUTOMATIONS_NEW_BADGE_STYLE_SETTING, 'unread'); - 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; - }); - }); - const container = harness.createContainer(); - const navigationContainer = mainWindow.document.createElement('div'); - const listContainer = mainWindow.document.createElement('div'); - container.append(navigationContainer, listContainer); - const list = harness.store.add(harness.instantiationService.createInstance(SessionsList, listContainer, { - grouping: () => SessionsGrouping.Date, - sorting: () => SessionsSorting.Created, - navigationContainer, - onSessionOpen: () => { }, - })); - list.layout(300, 400); - await list.resetAutomationsNewBadge(); - const row = navigationContainer.querySelector('.monaco-list-row'); - const before = row?.getAttribute('aria-label'); - - activeCustomView.set(upcastPartial({ id: AUTOMATIONS_CUSTOM_VIEW_ID }), undefined); - - assert.deepStrictEqual({ - before, - after: row?.getAttribute('aria-label'), - }, { - before: 'Automations, new feature', - after: 'Automations', - }); - }); - test('keeps New Session inactive when Automations opens', () => { const activeCustomView = observableValue(disposables, undefined); const draftSession = createTestSession('draft').session; @@ -923,7 +744,6 @@ suite('Sessions - SessionsList', () => { new class extends mock() { }, automationService, automationSessions, - constObservable(undefined), uriIdentityService, new class extends mock() { }, new class extends mock() { }, @@ -995,7 +815,6 @@ suite('Sessions - SessionsList', () => { new class extends mock() { }, automationService, constObservable([runningSession, needsInputSession]), - constObservable(undefined), uriIdentityService, new class extends mock() { }, new class extends mock() { }, @@ -7550,6 +7369,31 @@ suite('Sessions - SessionsList', () => { } } + for (const isExternal of [false, true]) { + test(`reports the exact session only after a revealed row finishes opening (external: ${isExternal})`, async () => { + const session = createTestSession('Guided session', { isExternal }).session; + const harness = createListHarness(disposables, [session]); + harness.instantiationService.stub(ICustomViewService, { hideCustomView: () => { }, activeCustomView: constObservable(undefined) }); + const completion = new DeferredPromise(); + const container = harness.createContainer(); + const list = harness.store.add(harness.instantiationService.createInstance(SessionsList, container, { + grouping: () => SessionsGrouping.Date, sorting: () => SessionsSorting.Created, + onSessionOpen: () => completion.p, + })); + const opened: string[] = []; + harness.store.add(list.onDidOpenSession(resource => opened.push(resource.toString()))); + list.layout(300, 400); + list.collapseAllSections(); + harness.store.add(list.revealSessionForOnboarding(session)); + clickRow(findSessionRow(container, 'Guided session')); + await settle(); + assert.deepStrictEqual(opened, []); + await completion.complete(); + await settle(); + assert.deepStrictEqual(opened, [session.resource.toString()]); + }); + } + function renderGatedList(title: string, canOpenSession?: (session: ISession) => Promise) { const { session } = createTestSession(title); const harness = createListHarness(disposables, [session]); diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsListFixtureUtils.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsListFixtureUtils.ts index ccbe33806e7238..0e5e0648d95c20 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsListFixtureUtils.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsListFixtureUtils.ts @@ -13,7 +13,7 @@ import { onUnexpectedError } from '../../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; -import { constObservable, IObservable, ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; +import { constObservable, IObservable } from '../../../../../base/common/observable.js'; import { OS } from '../../../../../base/common/platform.js'; import { ExtUri, isEqual } from '../../../../../base/common/resources.js'; import { hasKey } from '../../../../../base/common/types.js'; @@ -48,7 +48,6 @@ import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/ import { EditorMarkdownCodeBlockRenderer } from '../../../../../editor/browser/widget/markdownRenderer/browser/editorMarkdownCodeBlockRenderer.js'; import { AgentSessionApprovalKind, AgentSessionApprovalModel, IAgentSessionApprovalInfo } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; -import type { IAutomationRun } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; import { ChatAutomationsEnabledContext } from '../../../../../workbench/contrib/chat/common/automations/automationsEnabled.js'; import { IAutomationService } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; import { IChatService } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; @@ -56,7 +55,6 @@ import { IChatModel } from '../../../../../workbench/contrib/chat/common/model/c import { IVoicePlaybackService } from '../../../../../workbench/contrib/chat/common/voicePlaybackService.js'; import { IWorkbenchAssignmentService } from '../../../../../workbench/services/assignment/common/assignmentService.js'; import { IEditorService } from '../../../../../workbench/services/editor/common/editorService.js'; -import { ILifecycleService, LifecyclePhase } from '../../../../../workbench/services/lifecycle/common/lifecycle.js'; import { ComponentFixtureContext, ComponentFixtureOptions, createEditorServices, defineComponentFixture, registerWorkbenchServices } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; import { TestProductService } from '../../../../../workbench/test/common/workbenchTestServices.js'; import { Menus } from '../../../../browser/menus.js'; @@ -68,7 +66,6 @@ import { ISessionsListModelService, SessionsListModelService } from '../../../.. import { ISessionSectionOrderService, SessionSectionOrderService } 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 { IChat, ISession } from '../../../../services/sessions/common/session.js'; import { ISessionComparisonService } from '../../../../services/sessions/common/sessionComparison.js'; import { IActiveSession, ISessionsChangeEvent, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; @@ -165,8 +162,6 @@ export interface ISessionsListFixtureHeader { readonly newSessionButtonStyle?: NewSessionButtonStyle; /** Applied through the experiment treatment. */ readonly newSessionButtonTreatment?: NewSessionButtonStyle; - readonly automations?: boolean; - readonly automationRunStatus?: IAutomationRun['status']; /** Shows New Session, Automations, and Customizations as navigation rows above the Sessions header. */ readonly navigationShortcuts?: boolean; /** Customization count exposed in the navigation row's accessibility label. */ @@ -575,7 +570,6 @@ export async function renderSessionsListFixture(context: ComponentFixtureContext override readonly sessionId = spec.id; override readonly sticky: IObservable = constObservable(true); }()); - const automationRuns = observableValue('fixtureAutomationRuns', []); const newSessionButtonStyle = header?.newSessionButtonStyle ?? header?.newSessionButtonTreatment; const newSessionKeybinding = newSessionButtonStyle || header?.navigationShortcuts ? createUSLayoutResolvedKeybinding(KeyMod.CtrlCmd | KeyCode.KeyN, OS) : undefined; @@ -635,21 +629,13 @@ export async function renderSessionsListFixture(context: ComponentFixtureContext 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; } }()); reg.defineInstance(IAutomationService, new class extends mock() { override readonly automations = constObservable([]); - override readonly runs = automationRuns; + override readonly runs = constObservable([]); override readonly catalogueState = constObservable('ready' as const); }()); reg.defineInstance(IWorkbenchAssignmentService, new class extends mock() { @@ -696,7 +682,7 @@ export async function renderSessionsListFixture(context: ComponentFixtureContext if (view.phone) { IsPhoneLayoutContext.bindTo(contextKeyService).set(true); } - if (header?.automations || header?.navigationShortcuts) { + if (header?.navigationShortcuts) { ChatAutomationsEnabledContext.bindTo(contextKeyService).set(true); } @@ -782,7 +768,7 @@ export async function renderSessionsListFixture(context: ComponentFixtureContext } if (header) { - await renderHeaderState(list, container, instantiationService, header, automationRuns); + await renderHeaderState(container, instantiationService, header); } await timeout(SETTLE_DELAY); @@ -822,19 +808,7 @@ function setCollapsed(list: TestSessionsList, item: SessionsListItemReference, c } } -async function renderHeaderState(list: SessionsList, container: HTMLElement, instantiationService: TestInstantiationService, header: ISessionsListFixtureHeader, automationRuns: ISettableObservable): Promise { - if (header.automations) { - await list.resetAutomationsNewBadge(); - } - if (header.automationRunStatus) { - automationRuns.set([{ - id: 'fixture-run', - automationId: 'fixture-automation', - status: header.automationRunStatus, - trigger: 'schedule', - startedAt: new Date().toISOString(), - }], undefined); - } +async function renderHeaderState(container: HTMLElement, instantiationService: TestInstantiationService, header: ISessionsListFixtureHeader): Promise { await Promise.resolve(); if (header.newSessionButtonStyle) { const configurationService = instantiationService.get(IConfigurationService) as TestConfigurationService; diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsListNotice.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsListNotice.test.ts new file mode 100644 index 00000000000000..e810dd86180245 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsListNotice.test.ts @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { toDisposable } from '../../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { SessionsListNotice } from '../../browser/views/sessionsListNotice.js'; + +suite('SessionsListNotice', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + test('keyboard actions are labelled and Escape dismisses back to the list', () => { + const list = document.createElement('button'); + document.body.appendChild(list); + disposables.add(toDisposable(() => list.remove())); + const actions: string[] = []; + const notice = disposables.add(new SessionsListNotice({ + description: 'A session suggestion', label: 'Continue', disableLabel: 'Don\'t Show Again', + run: () => actions.push('continue'), disable: () => actions.push('disable'), dismiss: () => actions.push('dismiss'), + focusSessionsList: () => list.focus(), + })); + document.body.appendChild(notice.domNode); + disposables.add(toDisposable(() => notice.domNode.remove())); + const buttons = [...notice.domNode.querySelectorAll('[role="button"], button')]; + for (const button of buttons) { + button.focus(); + button.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + assert.strictEqual(document.activeElement, list); + } + for (const keyCode of [13, 32]) { + buttons[0].focus(); + buttons[0].dispatchEvent(new KeyboardEvent('keydown', { keyCode, bubbles: true })); + assert.strictEqual(document.activeElement, list); + } + buttons.find(button => button.textContent === 'Continue')!.click(); + buttons.find(button => button.textContent === 'Don\'t Show Again')!.click(); + assert.deepStrictEqual({ + actions, focusReturned: document.activeElement === list, + role: notice.domNode.getAttribute('role'), label: notice.domNode.getAttribute('aria-label'), + dismissLabel: buttons[0].getAttribute('aria-label'), + keyboardAccessible: buttons.length === 3 && buttons.every(button => button.tabIndex === 0), + }, { + actions: ['dismiss', 'dismiss', 'dismiss', 'dismiss', 'dismiss', 'continue', 'disable'], focusReturned: true, + role: 'region', label: 'Continue', dismissLabel: 'Dismiss Suggestion', keyboardAccessible: true, + }); + }); +}); diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsTitleBarWidget.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsTitleBarWidget.test.ts index ffdcdb0e455f1d..27d4bb558f256e 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsTitleBarWidget.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsTitleBarWidget.test.ts @@ -20,7 +20,6 @@ suite('SessionsTitleBarWidget', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); const getCommandCenterTitle = Reflect.get(SessionsTitleBarWidget.prototype, '_getCommandCenterTitle') as (this: SessionsTitleBarWidget) => string | undefined; - const getCommandCenterTitles = Reflect.get(SessionsTitleBarWidget.prototype, '_getCommandCenterTitles') as (this: SessionsTitleBarWidget) => { sessionTitle: string | undefined; contextTitle: string | undefined }; const renderActiveSession = Reflect.get(SessionsTitleBarWidget.prototype, '_renderActiveSession') as (this: SessionsTitleBarWidget) => void; const openBlockedSession = Reflect.get(SessionsTitleBarWidget.prototype, '_openBlockedSession') as (this: SessionsTitleBarWidget, resource: URI, preserveFocus: boolean, sideBySide: boolean) => void; @@ -45,66 +44,26 @@ suite('SessionsTitleBarWidget', () => { assert.deepStrictEqual(options, [{ preserveFocus: true, source: 'sessionsList', forceMainChat: true }]); }); - test('prepends the active session title to the command-center context', () => { + test('uses only the workspace context in the command center', () => { const widget = Object.create(SessionsTitleBarWidget.prototype) as SessionsTitleBarWidget; Reflect.set(widget, '_workspaceInfo', { label: 'vscode-tools' }); Reflect.set(widget, '_isQuickChat', false); Reflect.set(widget, '_sessionTitle', undefined); - Reflect.set(widget, '_activeSessionTitle', undefined); - Reflect.set(widget, '_activeChatTitle', undefined); - const standardPresentation = getCommandCenterTitles.call(widget); - - Reflect.set(widget, '_activeSessionTitle', 'General help'); - Reflect.set(widget, '_activeChatTitle', 'Current chat'); - const sessionViewPresentation = { - titles: getCommandCenterTitles.call(widget), - combinedTitle: getCommandCenterTitle.call(widget), - }; - - Reflect.set(widget, '_activeChatTitle', 'General help'); - const matchingChatTitlePresentation = { - titles: getCommandCenterTitles.call(widget), - combinedTitle: getCommandCenterTitle.call(widget), - }; - - Reflect.set(widget, '_activeChatTitle', 'Current chat'); + const workspaceTitle = getCommandCenterTitle.call(widget); Reflect.set(widget, '_sessionTitle', 'Custom context'); - const customContextPresentation = { - titles: getCommandCenterTitles.call(widget), - combinedTitle: getCommandCenterTitle.call(widget), - }; + const customContextTitle = getCommandCenterTitle.call(widget); + Reflect.set(widget, '_sessionTitle', undefined); + Reflect.set(widget, '_workspaceInfo', undefined); + Reflect.set(widget, '_isQuickChat', true); + const quickChatTitle = getCommandCenterTitle.call(widget); + Reflect.set(widget, '_isQuickChat', false); + const emptyTitle = getCommandCenterTitle.call(widget); - assert.deepStrictEqual({ - standardPresentation, - sessionViewPresentation, - matchingChatTitlePresentation, - customContextPresentation, - }, { - standardPresentation: { - sessionTitle: undefined, - contextTitle: 'vscode-tools', - }, - sessionViewPresentation: { - titles: { - sessionTitle: 'General help', - contextTitle: 'vscode-tools', - }, - combinedTitle: 'General help · vscode-tools', - }, - matchingChatTitlePresentation: { - titles: { - sessionTitle: undefined, - contextTitle: 'vscode-tools', - }, - combinedTitle: 'vscode-tools', - }, - customContextPresentation: { - titles: { - sessionTitle: 'General help', - contextTitle: 'Custom context', - }, - combinedTitle: 'General help · Custom context', - }, + assert.deepStrictEqual({ workspaceTitle, customContextTitle, quickChatTitle, emptyTitle }, { + workspaceTitle: 'vscode-tools', + customContextTitle: 'Custom context', + quickChatTitle: 'No workspace', + emptyTitle: undefined, }); }); @@ -123,8 +82,6 @@ suite('SessionsTitleBarWidget', () => { }); Reflect.set(widget, '_isQuickChat', false); Reflect.set(widget, '_sessionTitle', undefined); - Reflect.set(widget, '_activeSessionTitle', undefined); - Reflect.set(widget, '_activeChatTitle', undefined); Reflect.set(widget, 'hoverService', upcastPartial({ setupDelayedHover: () => Disposable.None })); renderActiveSession.call(widget); diff --git a/src/vs/sessions/sessions.desktop.main.ts b/src/vs/sessions/sessions.desktop.main.ts index 125d39cb23c22a..9a3fb30b12a5cf 100644 --- a/src/vs/sessions/sessions.desktop.main.ts +++ b/src/vs/sessions/sessions.desktop.main.ts @@ -264,6 +264,7 @@ import './contrib/chat/electron-browser/chat.contribution.js'; // Local Agent Host import './contrib/providers/agentHost/browser/localAgentHost.contribution.js'; +import './contrib/providers/agentHost/browser/codexContinuation.contribution.js'; import './contrib/providers/agentHost/electron-browser/localAgentHostLifecycle.contribution.js'; import './contrib/providers/agentHost/browser/agentSessionSettings.contribution.js'; import './contrib/providers/agentHost/browser/agentHostSettings.contribution.js'; diff --git a/src/vs/sessions/test/browser/agentsPartCard.test.ts b/src/vs/sessions/test/browser/agentsPartCard.test.ts index af454b60eb6746..4e72c8d37b2367 100644 --- a/src/vs/sessions/test/browser/agentsPartCard.test.ts +++ b/src/vs/sessions/test/browser/agentsPartCard.test.ts @@ -250,8 +250,10 @@ suite('Sessions - Agents Part Card', () => { container.classList.toggle('modern-ui-compact', compact); const part = { layoutService: { + mainContainer: container, isModernUICompact: () => compact, isVisible: () => true, + getPanelAlignment: () => 'justify' as const, }, }; const panelLayout = PanelPart.prototype.layout as (this: typeof part, width: number, height: number, top: number, left: number) => void; @@ -276,6 +278,63 @@ suite('Sessions - Agents Part Card', () => { ]); }); + test('keeps panel composite content within active horizontal insets', () => { + const { container } = createCard(true, true); + const panel = append(container, $('.part.panel')); + const content = append(panel, $('.content')); + panel.style.transition = 'none'; + const baseLayout = sinon.stub(AbstractPaneCompositePart.prototype, 'layout').callsFake((width, height) => { + content.style.width = `${width}px`; + content.style.height = `${height}px`; + }); + let sidebarVisible = true; + let editorPaneVisible = true; + let alignment: 'center' | 'justify' = 'justify'; + const part = { + layoutService: { + mainContainer: container, + isModernUICompact: () => false, + isVisible: (partId: Parts) => partId === Parts.PANEL_PART + || (partId === Parts.SIDEBAR_PART && sidebarVisible) + || ((partId === Parts.EDITOR_PART || partId === Parts.AUXILIARYBAR_PART) && editorPaneVisible), + getPanelAlignment: () => alignment, + }, + }; + const layoutPanel = PanelPart.prototype.layout as (this: typeof part, width: number, height: number, top: number, left: number) => void; + const states = [ + { name: 'full span', sidebar: true, editorPane: true, alignment: 'justify' as const, width: 798 }, + { name: 'full span without sidebar', sidebar: false, editorPane: true, alignment: 'justify' as const, width: 794 }, + { name: 'chat aligned', sidebar: true, editorPane: true, alignment: 'center' as const, width: 794 }, + { name: 'chat aligned without sidebar', sidebar: false, editorPane: true, alignment: 'center' as const, width: 790 }, + { name: 'chat aligned without side pane', sidebar: false, editorPane: false, alignment: 'center' as const, width: 794 }, + ]; + + const actual = states.map(state => { + sidebarVisible = state.sidebar; + editorPaneVisible = state.editorPane; + alignment = state.alignment; + container.classList.toggle('nosidebar', !state.sidebar); + container.classList.toggle('noeditorpane', !state.editorPane); + container.classList.toggle('panel-alignment-center', state.alignment === 'center'); + container.classList.toggle('panel-alignment-justify', state.alignment === 'justify'); + layoutPanel.call(part, 800, 200, 400, 0); + + return { + name: state.name, + renderedWidth: panel.clientWidth, + contentWidth: content.clientWidth, + layout: baseLayout.lastCall.args, + }; + }); + + assert.deepStrictEqual(actual, states.map(state => ({ + name: state.name, + renderedWidth: state.width, + contentWidth: state.width, + layout: [state.width, 194, 400, 0], + }))); + }); + test('keeps connected chat and editor frames flush in compact density', () => { const { container, card } = createCard(true, true); container.classList.add('modern-ui-tabs', 'modern-ui-connected-editor-tabs', 'dock-detail-panel'); @@ -359,8 +418,10 @@ suite('Sessions - Agents Part Card', () => { panel.style.transition = 'none'; const panelContent = append(panel, $('.content')); const layoutService = { + mainContainer: container, isModernUICompact: () => true, isVisible: () => true, + getPanelAlignment: () => 'justify' as const, }; const auxiliaryPart = { layoutService }; const panelPart = { layoutService }; diff --git a/src/vs/sessions/test/browser/layoutActions.test.ts b/src/vs/sessions/test/browser/layoutActions.test.ts index fbc457f5714f92..6f4ce546d27301 100644 --- a/src/vs/sessions/test/browser/layoutActions.test.ts +++ b/src/vs/sessions/test/browser/layoutActions.test.ts @@ -8,6 +8,7 @@ import { Codicon } from '../../../base/common/codicons.js'; import { ThemeIcon } from '../../../base/common/themables.js'; import { hasKey } from '../../../base/common/types.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; +import { isICommandActionToggleInfo } from '../../../platform/action/common/action.js'; import { isIMenuItem, isISubmenuItem, MenuId, MenuRegistry } from '../../../platform/actions/common/actions.js'; import { CommandsRegistry } from '../../../platform/commands/common/commands.js'; import { ServicesAccessor } from '../../../platform/instantiation/common/instantiation.js'; @@ -23,6 +24,7 @@ import { Menus } from '../../browser/menus.js'; import '../../browser/layoutActions.js'; const TOGGLE_PANEL_ACTION_ID = 'workbench.action.togglePanel'; +const TOGGLE_PANEL_ALIGNMENT_ACTION_ID = 'workbench.action.agentSessions.togglePanelAlignment'; suite('Sessions - Layout Actions', () => { @@ -127,6 +129,63 @@ suite('Sessions - Layout Actions', () => { }); }); + test('bottom panel exposes a single chat-alignment toggle in the overflow menu', async () => { + const alignmentItems = MenuRegistry.getMenuItems(MenuId.ViewTitle) + .filter(isIMenuItem) + .filter(item => item.command.id === TOGGLE_PANEL_ALIGNMENT_ACTION_ID) + .map(item => { + const toggled = item.command.toggled; + return { + id: item.command.id, + title: typeof item.command.title === 'string' ? item.command.title : item.command.title.value, + icon: item.command.icon, + group: item.group, + order: item.order, + when: item.when?.serialize(), + toggled: toggled ? (isICommandActionToggleInfo(toggled) ? toggled.condition : toggled).serialize() : undefined, + }; + }); + const panelTitleAlignmentItems = MenuRegistry.getMenuItems(Menus.PanelTitle) + .filter(isIMenuItem) + .filter(item => item.command.id === TOGGLE_PANEL_ALIGNMENT_ACTION_ID); + const alignments: string[] = []; + let alignment = 'justify'; + const command = CommandsRegistry.getCommand(TOGGLE_PANEL_ALIGNMENT_ACTION_ID); + assert.ok(command); + const accessor = { + get: () => ({ + getPanelAlignment: () => alignment, + setPanelAlignment: (value: string) => { + alignment = value; + alignments.push(value); + }, + }), + } as ServicesAccessor; + + await command.handler(accessor); + await command.handler(accessor); + + assert.deepStrictEqual({ + alignmentItems, + panelTitleAlignmentItemCount: panelTitleAlignmentItems.length, + alignments, + }, { + alignmentItems: [ + { + id: TOGGLE_PANEL_ALIGNMENT_ACTION_ID, + title: 'Align Panel with Chat', + icon: undefined, + group: '1_layout', + order: 1, + when: `!sessionsIsPhoneLayout && viewLocation == 'panel'`, + toggled: 'panelAlignment == \'center\'', + }, + ], + panelTitleAlignmentItemCount: 0, + alignments: ['center', 'justify'], + }); + }); + test('core auxiliary bar command delegates to the layout service', async () => { let calls = 0; const command = CommandsRegistry.getCommand(ToggleAuxiliaryBarAction.ID); diff --git a/src/vs/sessions/test/browser/workbench.test.ts b/src/vs/sessions/test/browser/workbench.test.ts index 9701405e890cea..19a10909416d5a 100644 --- a/src/vs/sessions/test/browser/workbench.test.ts +++ b/src/vs/sessions/test/browser/workbench.test.ts @@ -4,13 +4,17 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { $, append } from '../../../base/browser/dom.js'; +import { Direction, Grid, ISerializableView, ISerializedGrid, ISerializedNode, LayoutPriority, SerializableGrid, Sizing } from '../../../base/browser/ui/grid/grid.js'; import { DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; import { IObservable, observableValue } from '../../../base/common/observable.js'; import { SashState } from '../../../base/browser/ui/sash/sash.js'; import { mainWindow } from '../../../base/browser/window.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; +import { TestView } from '../../../base/test/browser/ui/grid/util.js'; +import { StorageScope, StorageTarget } from '../../../platform/storage/common/storage.js'; import { Part } from '../../../workbench/browser/part.js'; -import { IPartVisibilityChangeEvent, LayoutSettings, ModernUIDensity, Parts } from '../../../workbench/services/layout/browser/layoutService.js'; +import { IPartVisibilityChangeEvent, LayoutSettings, ModernUIDensity, PanelAlignment, Parts } from '../../../workbench/services/layout/browser/layoutService.js'; import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../platform/configuration/test/common/testConfigurationService.js'; import { DockedAuxiliaryBarController, IDockedAuxiliaryBarHost } from '../../browser/dockedAuxiliaryBarController.js'; @@ -30,6 +34,26 @@ import { EditorInput } from '../../../workbench/common/editor/editorInput.js'; import '../../browser/parts/media/chatCompositeBar.css'; interface IViewSize { width: number; height: number } +type TestSerializedGrid = Omit & { root: Extract }; + +class TestPartView extends TestView implements ISerializableView { + get priority(): LayoutPriority { + return this.part === Parts.SESSIONS_PART ? LayoutPriority.High : LayoutPriority.Normal; + } + + get snap(): boolean { + return this.part === Parts.EDITOR_PART; + } + + constructor(readonly part: Parts) { + const minimumWidth = part === Parts.SESSIONS_PART || part === Parts.EDITOR_PART ? 300 : 0; + super(minimumWidth, Number.POSITIVE_INFINITY, 0, Number.POSITIVE_INFINITY); + } + + toJSON(): object { + return { type: this.part }; + } +} /** Minimal docked editor input for testing the desktop reveal policy. */ class TestDockedEditorInput extends DockedEditorInput { @@ -74,7 +98,17 @@ suite('Sessions - Workbench', () => { const applyPersistedPartVisibility = Reflect.get(Workbench.prototype, '_applyPersistedPartVisibility') as (this: IWorkbenchTestHarness) => void; const revealEditorOnOpen = Reflect.get(Workbench.prototype, 'revealEditorOnOpen') as (this: IWillOpenTestHarness, e: { groupId: number; editor: unknown }) => void; const revealEditorOnOpenDesktop = Reflect.get(DesktopWorkbench.prototype, 'revealEditorOnOpen') as (this: IWillOpenTestHarness, e: { groupId: number; editor: unknown }) => void; - const createDesktopGridDescriptor = Reflect.get(Workbench.prototype, 'createDesktopGridDescriptor') as (this: IGridDescriptorTestHarness, width: number, height: number) => { root: { data: readonly unknown[] } }; + const createDesktopGridDescriptor = Reflect.get(Workbench.prototype, 'createDesktopGridDescriptor') as (this: IGridDescriptorTestHarness, width: number, height: number) => TestSerializedGrid; + const loadPanelAlignment = Reflect.get(Workbench.prototype, '_loadPanelAlignment') as (this: Pick, storageService: { get(key: string, scope: StorageScope): string | undefined }) => PanelAlignment; + const setPanelAlignment = Workbench.prototype.setPanelAlignment as (this: IPanelAlignmentTestHarness, alignment: PanelAlignment) => void; + const registerGridMaximizationListener = Reflect.get(Workbench.prototype, '_registerGridMaximizationListener') as (this: { + workbenchGrid: Pick; + _register(disposable: T): T; + _fireDidChangePartVisibility(partId: Parts, visible: boolean): void; + isVisible(part: Parts): boolean; + }) => void; + const updateSessionsSidePaneDivider = Reflect.get(Workbench.prototype, '_updateSessionsSidePaneDivider') as (this: { workbenchGrid: { element: HTMLElement }; sessionsPartView: ISerializableView; editorPartView: ISerializableView }) => void; + const toggleMaximizedPanel = Workbench.prototype.toggleMaximizedPanel as (this: IPanelMaximizationTestHarness) => void; const savePartSizes = Reflect.get(Workbench.prototype, '_savePartSizes') as (this: ISavePartSizesTestHarness) => void; const isEditorPaneVisible = Workbench.prototype.isEditorPaneVisible as (this: ITestWorkbench) => boolean; const isDesktopEditorPaneVisible = DesktopWorkbench.prototype.isEditorPaneVisible as (this: ITestWorkbench) => boolean; @@ -112,6 +146,7 @@ suite('Sessions - Workbench', () => { _restoreSidePaneEditorMaximizedOnShow: boolean; _hasAppliedInitialEditorSplit: boolean; _dockedAuxiliaryBarWidth: number; + _syncingEditorVisibility: boolean; _memento: DockedEditorSizeMemento; readonly resizes: IViewSize[]; readonly distributions: object[]; @@ -143,6 +178,7 @@ suite('Sessions - Workbench', () => { } interface IGridDescriptorTestHarness extends ITestWorkbench { + _panelAlignment: PanelAlignment; _savedPartSizes: { sidebar?: number; auxiliaryBar?: number; editor?: number; sessions?: number; panel?: number }; layoutPolicy: { getPartSizes(width: number, height: number): { sideBarSize: number; auxiliaryBarSize: number; panelSize: number }; @@ -151,6 +187,63 @@ suite('Sessions - Workbench', () => { titleBarPartView: { minimumHeight: number }; } + interface IPanelAlignmentTestHarness { + agentWorkbenchLayout: AgentWorkbenchLayout; + _panelAlignment: PanelAlignment; + _panelMaximizedEditorState?: { width: number | undefined }; + _maximumEditorDimensionsOverride?: IViewSize; + panelPartView: object; + editorPartView: object; + sessionsPartView: object; + customViewGridPartView: object; + workbenchGrid: { + element?: HTMLElement; + isViewVisible(view: object): boolean; + isViewMaximized(view: object): boolean; + getViewSize(view: object): IViewSize; + getViewCachedVisibleSize(view: object): number | undefined; + setViewVisible?(view: object, visible: boolean): void; + moveView(view: object, size: number | Sizing, referenceView: object, direction: Direction): void; + resizeView(view: object, size: IViewSize): void; + exitMaximizedView(): void; + }; + mainContainer: { classList: { toggle(name: string, force: boolean): void } }; + getMaximumEditorDimensions(container: object): IViewSize; + storageService: { store(key: string, value: string, scope: StorageScope, target: StorageTarget): void }; + _onDidChangePanelAlignment: { fire(alignment: PanelAlignment): void }; + _runWithEditorResizeSyncSuspended(fn: () => void): void; + _layoutGrid(): void; + hasFocus(part: Parts): boolean; + focusPart(part: Parts): void; + } + + interface IPanelMaximizationTestHarness { + _panelMaximizedEditorState?: { width: number | undefined }; + panelPartView: object; + editorPartView: object; + titleBarPartView: object; + sideBarPartView: object; + workbenchGrid: { + isViewMaximized(view: object): boolean; + isViewVisible(view: object): boolean; + getViewSize(view: object): IViewSize; + maximizeView(view: object, hiddenViews: object[]): void; + resizeView(view: object, size: IViewSize): void; + exitMaximizedView(): void; + }; + _runWithEditorResizeSyncSuspended(fn: () => void): void; + isPanelMaximized(): boolean; + } + + type PartTopology = Parts | PartTopology[]; + + function getPartTopology(node: ISerializedNode): PartTopology { + if (node.type === 'leaf') { + return (node.data as { type: Parts }).type; + } + return node.data.map(getPartTopology); + } + interface IProportionalResizeTestHarness { partVisibility: { auxiliaryBar: boolean }; sessionsPartView: { minimumWidth: number }; @@ -316,6 +409,7 @@ suite('Sessions - Workbench', () => { _savedPartSizes: {}, _editorRevealedExplicitly: false, _editorMaximized: false, + _panelAlignment: 'justify', _editorPartAutoVisibilitySuppressionCount: options.suppressionCount ?? 0, _restoreAttachedEditorMaximizedOnShow: false, _restoreSidePaneEditorMaximizedOnShow: false, @@ -459,6 +553,38 @@ suite('Sessions - Workbench', () => { } }); + test('keeps the bottom panel inset when the primary sidebar is hidden', () => { + const root = append(mainWindow.document.body, $('.monaco-workbench.agent-sessions-workbench')); + root.style.cssText = 'width: 800px; --vscode-agents-layout-floatingPanelGap: 4px;'; + const panel = append(root, $('.part.panel')); + panel.style.transition = 'none'; + const gaps = (classes: string) => { + root.className = `monaco-workbench agent-sessions-workbench ${classes}`; + const rootBounds = root.getBoundingClientRect(); + const panelBounds = panel.getBoundingClientRect(); + return { + left: panelBounds.left - rootBounds.left, + right: rootBounds.right - panelBounds.right, + }; + }; + + try { + assert.deepStrictEqual({ + sidebarVisible: gaps('panel-alignment-justify'), + sidebarHidden: gaps('nosidebar panel-alignment-justify'), + sidebarHiddenCentered: gaps('nosidebar panel-alignment-center'), + phone: gaps('nosidebar phone-layout panel-alignment-justify'), + }, { + sidebarVisible: { left: 0, right: 0 }, + sidebarHidden: { left: 4, right: 0 }, + sidebarHiddenCentered: { left: 4, right: 4 }, + phone: { left: 0, right: 0 }, + }); + } finally { + root.remove(); + } + }); + test('matches only the Agents cards next to macOS window corners', () => { const root = document.createElement('div'); root.style.cssText = '--vscode-cornerRadius-large: 8px; --vscode-agents-layout-floatingPanelGap: 4px; --vscode-strokeThickness: 1px; --window-zoom-factor: 1;'; @@ -514,6 +640,11 @@ suite('Sessions - Workbench', () => { sessionsAbovePanel: corners(sessions, 'noeditorpane'), editorAbovePanel: corners(editor, 'noauxiliarybar'), panel: corners(panel, ''), + panelWithoutSidebar: corners(panel, 'nosidebar'), + centeredPanel: corners(panel, 'panel-alignment-center dock-detail-panel'), + centeredPanelWithoutSidebar: corners(panel, 'panel-alignment-center dock-detail-panel nosidebar'), + centeredEditor: corners(editor, 'panel-alignment-center dock-detail-panel'), + centeredPanelWithoutSidePane: corners(panel, 'panel-alignment-center dock-detail-panel noeditorpane'), }, { sessions: ['8px', '8px', '12px', '8px'], sessionsWithoutSidebar: ['8px', '8px', '12px', '12px'], @@ -537,6 +668,11 @@ suite('Sessions - Workbench', () => { sessionsAbovePanel: ['8px', '8px', '8px', '8px'], editorAbovePanel: ['8px', '8px', '8px', '8px'], panel: ['8px', '8px', '12px', '8px'], + panelWithoutSidebar: ['8px', '8px', '12px', '12px'], + centeredPanel: ['8px', '8px', '8px', '8px'], + centeredPanelWithoutSidebar: ['8px', '8px', '8px', '12px'], + centeredEditor: ['8px', '8px', '12px', '8px'], + centeredPanelWithoutSidePane: ['8px', '8px', '12px', '8px'], }); } finally { root.remove(); @@ -934,6 +1070,44 @@ suite('Sessions - Workbench', () => { assert.deepStrictEqual({ size: editorNode.size, visible: editorNode.visible }, { size: 300, visible: true }); }); + test('desktop descriptor supports spanning and chat-aligned panels', () => { + const topology = (alignment: PanelAlignment) => { + const host = createHost({ single: true }) as IGridDescriptorTestHarness & { _panelAlignment: PanelAlignment }; + host._panelAlignment = alignment; + host.layoutPolicy = { + getPartSizes: () => ({ sideBarSize: 280, auxiliaryBarSize: 340, panelSize: 300 }), + viewportClass: { get: () => 'desktop' }, + }; + host.titleBarPartView = { minimumHeight: 30 }; + return getPartTopology(createDesktopGridDescriptor.call(host, 1200, 800).root); + }; + + assert.deepStrictEqual({ + justify: topology('justify'), + center: topology('center'), + }, { + justify: [ + Parts.TITLEBAR_PART, + [ + Parts.SIDEBAR_PART, + [ + [Parts.SESSIONS_PART, Parts.EDITOR_PART, Parts.CUSTOM_VIEW_GRID_PART], + Parts.PANEL_PART, + ], + ], + ], + center: [ + Parts.TITLEBAR_PART, + [ + Parts.SIDEBAR_PART, + [Parts.SESSIONS_PART, Parts.PANEL_PART], + Parts.EDITOR_PART, + Parts.CUSTOM_VIEW_GRID_PART, + ], + ], + }); + }); + test('desktop container resize preserves the sessions/editor ratio', () => { const sessionsPartView = { minimumWidth: 300 }; const editorPartView = { minimumWidth: 300 }; @@ -2103,6 +2277,20 @@ suite('Sessions - Workbench', () => { // --- Docked editor hide-sync (grid sash / editor part layout) ----------- + test('does not inspect a transient grid while editor resize sync is suspended', () => { + const host = createHost({ single: true }); + let inspectedGrid = false; + host._syncingEditorVisibility = true; + host.workbenchGrid.getViewSize = () => { + inspectedGrid = true; + throw new Error('Grid view is temporarily detached'); + }; + + onGridDidChange.call(host); + + assert.strictEqual(inspectedGrid, false); + }); + test('does not reveal the docked editor when the grid sash widens the node while only the detail is shown', () => { const host = createHost({ single: true, sessionsWidth: 1000, dockedWidth: 300, editorWidth: 305 }); host._memento.dockedEditorSizeBeforeHide = { width: 900, height: 800 }; @@ -3009,6 +3197,586 @@ suite('Sessions - Workbench', () => { // --- Panel visibility --------------------------------------------------- + test('restores only supported desktop panel alignment', () => { + const storageService = (value: string | undefined) => ({ get: () => value }); + + assert.deepStrictEqual({ + desktopCenter: loadPanelAlignment.call({ agentWorkbenchLayout: AgentWorkbenchLayout.Desktop }, storageService('center')), + desktopUnsupported: loadPanelAlignment.call({ agentWorkbenchLayout: AgentWorkbenchLayout.Desktop }, storageService('left')), + mobileCenter: loadPanelAlignment.call({ agentWorkbenchLayout: AgentWorkbenchLayout.Mobile }, storageService('center')), + }, { + desktopCenter: 'center', + desktopUnsupported: 'justify', + mobileCenter: 'justify', + }); + }); + + test('switches and persists desktop panel alignment without changing sizes', () => { + const panelPartView = {}; + const editorPartView = {}; + const sessionsPartView = {}; + const customViewGridPartView = {}; + const moves: { view: string; size: number | object; reference: string; direction: Direction }[] = []; + const resizes: { view: string; size: IViewSize }[] = []; + const stores: { key: string; value: string; scope: StorageScope; target: StorageTarget }[] = []; + const events: PanelAlignment[] = []; + const classToggles: { name: string; force: boolean }[] = []; + const focusedParts: Parts[] = []; + const editorDimensionOverrides: (IViewSize | undefined)[] = []; + const maximizedExitSuspensionStates: boolean[] = []; + let editorResizeSyncSuspended = false; + let panelMaximized = false; + let layoutCount = 0; + const viewNames = new Map([ + [panelPartView, 'panel'], + [editorPartView, 'editor'], + [sessionsPartView, 'sessions'], + [customViewGridPartView, 'customViewGrid'], + ]); + const host: IPanelAlignmentTestHarness = { + agentWorkbenchLayout: AgentWorkbenchLayout.Desktop, + _panelAlignment: 'justify', + panelPartView, + editorPartView, + sessionsPartView, + customViewGridPartView, + workbenchGrid: { + isViewVisible: () => true, + isViewMaximized: () => panelMaximized, + getViewSize: view => view === panelPartView ? { width: 1200, height: 320 } : { width: 640, height: 800 }, + getViewCachedVisibleSize: () => undefined, + moveView: (view, size, referenceView, direction) => { + editorDimensionOverrides.push(host._maximumEditorDimensionsOverride); + moves.push({ + view: viewNames.get(view)!, + size, + reference: viewNames.get(referenceView)!, + direction, + }); + }, + resizeView: (view, size) => { + editorDimensionOverrides.push(host._maximumEditorDimensionsOverride); + resizes.push({ view: viewNames.get(view)!, size }); + }, + exitMaximizedView: () => { + maximizedExitSuspensionStates.push(editorResizeSyncSuspended); + panelMaximized = false; + }, + }, + mainContainer: { classList: { toggle: (name, force) => classToggles.push({ name, force }) } }, + getMaximumEditorDimensions: () => ({ width: 1200, height: 500 }), + storageService: { store: (key, value, scope, target) => stores.push({ key, value, scope, target }) }, + _onDidChangePanelAlignment: { fire: alignment => events.push(alignment) }, + _runWithEditorResizeSyncSuspended: fn => { + editorResizeSyncSuspended = true; + try { + fn(); + } finally { + editorResizeSyncSuspended = false; + } + }, + _layoutGrid: () => layoutCount++, + hasFocus: part => part === Parts.PANEL_PART, + focusPart: part => focusedParts.push(part), + }; + Object.setPrototypeOf(host, Workbench.prototype); + + setPanelAlignment.call(host, 'center'); + panelMaximized = true; + host._panelMaximizedEditorState = { width: 720 }; + setPanelAlignment.call(host, 'justify'); + setPanelAlignment.call(host, 'left'); + + assert.deepStrictEqual({ + alignment: host._panelAlignment, + moves, + resizes, + stores, + events, + classToggles, + focusedParts, + editorDimensionOverrides, + maximizedExitSuspensionStates, + layoutCount, + }, { + alignment: 'justify', + moves: [ + { view: 'panel', size: 320, reference: 'sessions', direction: Direction.Down }, + { view: 'editor', size: 720, reference: 'sessions', direction: Direction.Right }, + { view: 'customViewGrid', size: 640, reference: 'editor', direction: Direction.Right }, + ], + resizes: [ + { view: 'sessions', size: { width: 640, height: 800 } }, + { view: 'editor', size: { width: 720, height: 800 } }, + ], + stores: [ + { key: 'workbench.sessions.panelAlignment', value: 'center', scope: StorageScope.PROFILE, target: StorageTarget.USER }, + { key: 'workbench.sessions.panelAlignment', value: 'justify', scope: StorageScope.PROFILE, target: StorageTarget.USER }, + ], + events: ['center', 'justify'], + classToggles: [ + { name: 'panel-alignment-justify', force: false }, + { name: 'panel-alignment-center', force: true }, + { name: 'panel-alignment-center', force: false }, + { name: 'panel-alignment-justify', force: true }, + ], + focusedParts: [Parts.PANEL_PART, Parts.PANEL_PART], + editorDimensionOverrides: [ + { width: 1200, height: 500 }, + { width: 1200, height: 500 }, + { width: 1200, height: 500 }, + { width: 1200, height: 500 }, + { width: 1200, height: 500 }, + ], + maximizedExitSuspensionStates: [true], + layoutCount: 2, + }); + }); + + test('keeps editor resize sync suspended while restoring detail-only panel maximization', () => { + const host = createHost({ + single: true, + partVisibility: { editor: false, auxiliaryBar: true, panel: true }, + }) as ITestWorkbench & IPanelAlignmentTestHarness; + let panelMaximized = true; + let editorDetached = false; + const detailVisibilityCallbacks: boolean[] = []; + const gridChangeSuspensionStates: boolean[] = []; + const getViewSize = (view: object): IViewSize => { + if (view === host.editorPartView && editorDetached) { + throw new Error('Invalid grid element'); + } + if (view === host.panelPartView) { + return { width: 1200, height: 320 }; + } + if (view === host.editorPartView) { + return { width: 300, height: 800 }; + } + return { width: 640, height: 800 }; + }; + const fireGridChangeWhileEditorIsDetached = () => { + gridChangeSuspensionStates.push(host._syncingEditorVisibility); + editorDetached = true; + try { + onGridDidChange.call(host); + } finally { + editorDetached = false; + } + }; + host._panelAlignment = 'center'; + host._panelMaximizedEditorState = { width: 300 }; + host.workbenchGrid = { + isViewVisible: () => true, + isViewMaximized: view => view === host.panelPartView && panelMaximized, + getViewSize, + getViewCachedVisibleSize: () => undefined, + setViewVisible: () => { }, + moveView: () => fireGridChangeWhileEditorIsDetached(), + resizeView: () => { + gridChangeSuspensionStates.push(host._syncingEditorVisibility); + onGridDidChange.call(host); + }, + exitMaximizedView: () => { + panelMaximized = false; + detailVisibilityCallbacks.push(true); + onEditorPartGridVisibilityChange.call(host, true); + }, + }; + host.getMaximumEditorDimensions = () => ({ width: 1200, height: 500 }); + host.storageService = { store: () => { } }; + host._onDidChangePanelAlignment = { fire: () => { } }; + host._layoutGrid = () => { }; + + detailVisibilityCallbacks.push(false); + onEditorPartGridVisibilityChange.call(host, false); + setPanelAlignment.call(host, 'justify'); + + assert.deepStrictEqual({ + alignment: host._panelAlignment, + auxiliaryBarVisible: host.partVisibility.auxiliaryBar, + detailVisibilityCallbacks, + gridChangeSuspensionStates, + panelMaximized, + }, { + alignment: 'justify', + auxiliaryBarVisible: true, + detailVisibilityCallbacks: [false, true], + gridChangeSuspensionStates: [true, true, true, true, true], + panelMaximized: false, + }); + }); + + test('marks the Sessions and side-pane divider in initial and reparented grid DOM', () => { + const localStore = new DisposableStore(); + const root = append(mainWindow.document.body, $('.monaco-workbench.agent-sessions-workbench.panel-alignment-center')); + try { + const descriptorHost = createHost({ + single: true, + windowWidth: 1200, + partVisibility: { sidebar: true, editor: true, auxiliaryBar: true, panel: true }, + }) as IGridDescriptorTestHarness; + descriptorHost._panelAlignment = 'center'; + descriptorHost.layoutPolicy = { + getPartSizes: () => ({ sideBarSize: 280, auxiliaryBarSize: 340, panelSize: 300 }), + viewportClass: { get: () => 'desktop' }, + }; + descriptorHost.titleBarPartView = { minimumHeight: 30 }; + + const views = new Map(); + const grid = localStore.add(SerializableGrid.deserialize(createDesktopGridDescriptor.call(descriptorHost, 1200, 800), { + fromJSON: ({ type }: { type: Parts }) => { + const view = localStore.add(new TestPartView(type)); + view.element.classList.add('part'); + if (type === Parts.SESSIONS_PART) { + view.element.classList.add('sessionspart'); + } else if (type === Parts.EDITOR_PART) { + view.element.classList.add('editor'); + } + views.set(type, view); + return view; + }, + }, { proportionalLayout: false })); + root.appendChild(grid.element); + grid.layout(1200, 800); + + const sessionsPartView = views.get(Parts.SESSIONS_PART)!; + const editorPartView = views.get(Parts.EDITOR_PART)!; + const markerHost = { workbenchGrid: grid, sessionsPartView, editorPartView }; + updateSessionsSidePaneDivider.call(markerHost); + localStore.add(grid.onDidChange(() => updateSessionsSidePaneDivider.call(markerHost))); + + const getDividerPosition = () => { + const divider = grid.element.querySelector('.sessions-side-pane-divider'); + assert.ok(divider); + const splitView = divider.closest('.monaco-split-view2.horizontal'); + assert.ok(splitView); + const viewContainer = splitView.querySelector(':scope > .monaco-scrollable-element > .split-view-container'); + const sashContainer = splitView.querySelector(':scope > .sash-container'); + assert.ok(viewContainer); + assert.ok(sashContainer); + const splitViews = Array.from(viewContainer.children); + const sashes = Array.from(sashContainer.children); + return { + dividerCount: grid.element.querySelectorAll('.sessions-side-pane-divider').length, + dividerIndex: sashes.indexOf(divider), + sessionsIndex: splitViews.findIndex(view => view.contains(sessionsPartView.element)), + editorIndex: splitViews.findIndex(view => view.contains(editorPartView.element)), + }; + }; + + const centered = getDividerPosition(); + const getView = (view: object): TestPartView => { + if (!(view instanceof TestPartView)) { + throw new Error('Expected a test part view'); + } + return view; + }; + const alignmentHost: IPanelAlignmentTestHarness = { + agentWorkbenchLayout: AgentWorkbenchLayout.Desktop, + _panelAlignment: 'center', + panelPartView: views.get(Parts.PANEL_PART)!, + sessionsPartView, + editorPartView, + customViewGridPartView: views.get(Parts.CUSTOM_VIEW_GRID_PART)!, + workbenchGrid: { + element: grid.element, + isViewVisible: view => grid.isViewVisible(getView(view)), + isViewMaximized: view => grid.isViewMaximized(getView(view)), + getViewSize: view => grid.getViewSize(getView(view)), + getViewCachedVisibleSize: view => grid.getViewCachedVisibleSize(getView(view)), + moveView: (view, size, referenceView, direction) => grid.moveView(getView(view), size, getView(referenceView), direction), + resizeView: (view, size) => grid.resizeView(getView(view), size), + exitMaximizedView: () => grid.exitMaximizedView(), + }, + mainContainer: root, + getMaximumEditorDimensions: () => ({ width: 1200, height: 500 }), + storageService: { store: () => { } }, + _onDidChangePanelAlignment: { fire: () => { } }, + _runWithEditorResizeSyncSuspended: fn => fn(), + _layoutGrid: () => grid.layout(1200, 800), + hasFocus: () => false, + focusPart: () => { }, + }; + Object.setPrototypeOf(alignmentHost, Workbench.prototype); + setPanelAlignment.call(alignmentHost, 'justify'); + + assert.deepStrictEqual({ centered, justified: getDividerPosition() }, { + centered: { dividerCount: 1, dividerIndex: 1, sessionsIndex: 1, editorIndex: 2 }, + justified: { dividerCount: 1, dividerIndex: 0, sessionsIndex: 0, editorIndex: 1 }, + }); + } finally { + root.remove(); + localStore.dispose(); + } + }); + + test('restores the focused grid descendant after panel alignment reparents it', () => { + const root = append(mainWindow.document.body, $('.agent-sessions-workbench')); + const sessionsContainer = append(root, $('.part.sessionspart')); + const input = append(sessionsContainer, $('input')); + const panelPartView = {}; + const editorPartView = {}; + const sessionsPartView = {}; + const customViewGridPartView = {}; + const focusedParts: Parts[] = []; + let reparented = false; + const host: IPanelAlignmentTestHarness = { + agentWorkbenchLayout: AgentWorkbenchLayout.Desktop, + _panelAlignment: 'justify', + panelPartView, + editorPartView, + sessionsPartView, + customViewGridPartView, + workbenchGrid: { + element: root, + isViewVisible: () => true, + isViewMaximized: () => false, + getViewSize: view => view === panelPartView ? { width: 1200, height: 320 } : { width: 640, height: 800 }, + getViewCachedVisibleSize: () => undefined, + moveView: () => { + if (!reparented) { + reparented = true; + sessionsContainer.remove(); + root.appendChild(sessionsContainer); + } + }, + resizeView: () => { }, + exitMaximizedView: () => { }, + }, + mainContainer: root, + getMaximumEditorDimensions: () => ({ width: 1200, height: 500 }), + storageService: { store: () => { } }, + _onDidChangePanelAlignment: { fire: () => { } }, + _runWithEditorResizeSyncSuspended: fn => fn(), + _layoutGrid: () => { }, + hasFocus: part => part === Parts.SESSIONS_PART, + focusPart: part => focusedParts.push(part), + }; + Object.setPrototypeOf(host, Workbench.prototype); + + try { + input.focus(); + setPanelAlignment.call(host, 'center'); + + assert.deepStrictEqual({ + activeElement: mainWindow.document.activeElement, + focusedParts, + }, { + activeElement: input, + focusedParts: [], + }); + } finally { + root.remove(); + } + }); + + test('panel maximization preserves whether the editor node had a restorable width', () => { + const panelPartView = {}; + const editorPartView = {}; + const titleBarPartView = {}; + const sideBarPartView = {}; + const calls: string[] = []; + const resizes: IViewSize[] = []; + let editorVisible = true; + let editorResizeSyncSuspended = false; + let maximized = false; + const host: IPanelMaximizationTestHarness = { + panelPartView, + editorPartView, + titleBarPartView, + sideBarPartView, + workbenchGrid: { + isViewMaximized: view => view === panelPartView && maximized, + isViewVisible: view => view === editorPartView && editorVisible, + getViewSize: () => ({ width: 578, height: 800 }), + maximizeView: () => { + calls.push(`maximize:${editorResizeSyncSuspended}`); + maximized = true; + }, + resizeView: (view, size) => { + calls.push(`resize:${view === editorPartView}:${editorResizeSyncSuspended}`); + resizes.push(size); + }, + exitMaximizedView: () => { + calls.push(`restore:${editorResizeSyncSuspended}`); + maximized = false; + }, + }, + _runWithEditorResizeSyncSuspended: fn => { + editorResizeSyncSuspended = true; + try { + fn(); + } finally { + editorResizeSyncSuspended = false; + } + }, + isPanelMaximized: () => maximized, + }; + + toggleMaximizedPanel.call(host); + const visibleEditorState = host._panelMaximizedEditorState; + toggleMaximizedPanel.call(host); + const restoredEditorState = host._panelMaximizedEditorState; + editorVisible = false; + toggleMaximizedPanel.call(host); + const hiddenEditorState = host._panelMaximizedEditorState; + + assert.deepStrictEqual({ + calls, + resizes, + visibleEditorState, + restoredEditorState, + hiddenEditorState, + }, { + calls: ['maximize:true', 'restore:true', 'resize:true:true', 'maximize:true'], + resizes: [{ width: 578, height: 800 }], + visibleEditorState: { width: 578 }, + restoredEditorState: undefined, + hiddenEditorState: { width: undefined }, + }); + }); + + test('forwards panel maximization after the Grid commits its state', () => { + const localStore = new DisposableStore(); + try { + const panel = localStore.add(new TestView(50, Number.MAX_VALUE, 50, Number.MAX_VALUE)); + const editor = localStore.add(new TestView(50, Number.MAX_VALUE, 50, Number.MAX_VALUE)); + const grid = localStore.add(new Grid(panel)); + grid.addView(editor, Sizing.Distribute, panel, Direction.Up); + grid.layout(800, 600); + const notifications: { partId: Parts; visible: boolean; maximized: boolean }[] = []; + const host = { + workbenchGrid: grid, + _register: (disposable: T) => localStore.add(disposable), + _fireDidChangePartVisibility: (partId: Parts, visible: boolean) => { + notifications.push({ partId, visible, maximized: grid.hasMaximizedView() }); + }, + isVisible: () => true, + }; + registerGridMaximizationListener.call(host); + + grid.maximizeView(panel); + grid.exitMaximizedView(); + + assert.deepStrictEqual(notifications, [ + { partId: Parts.PANEL_PART, visible: true, maximized: true }, + { partId: Parts.PANEL_PART, visible: true, maximized: false }, + ]); + } finally { + localStore.dispose(); + } + }); + + test('round trips a detail-only pane and preserves hidden views through panel maximization', () => { + const store = new DisposableStore(); + try { + const host = createHost({ single: true, partVisibility: { sidebar: false, editor: false, auxiliaryBar: true, panel: true } }) as IGridDescriptorTestHarness; + host.layoutPolicy = { + getPartSizes: () => ({ sideBarSize: 280, auxiliaryBarSize: 340, panelSize: 300 }), + viewportClass: { get: () => 'desktop' }, + }; + host.titleBarPartView = { minimumHeight: 30 }; + + const descriptor = createDesktopGridDescriptor.call(host, 1200, 800); + const views = new Map(); + const grid = store.add(SerializableGrid.deserialize(descriptor, { + fromJSON: ({ type }: { type: Parts }) => { + const view = store.add(new TestPartView(type)); + views.set(type, view); + return view; + }, + }, { proportionalLayout: false })); + grid.layout(1200, 800); + + const getView = (view: object): TestPartView => { + if (!(view instanceof TestPartView)) { + throw new Error('Expected a test part view'); + } + return view; + }; + const alignmentHost: IPanelAlignmentTestHarness = { + agentWorkbenchLayout: AgentWorkbenchLayout.Desktop, + _panelAlignment: 'justify', + panelPartView: views.get(Parts.PANEL_PART)!, + sessionsPartView: views.get(Parts.SESSIONS_PART)!, + editorPartView: views.get(Parts.EDITOR_PART)!, + customViewGridPartView: views.get(Parts.CUSTOM_VIEW_GRID_PART)!, + workbenchGrid: { + isViewVisible: view => grid.isViewVisible(getView(view)), + isViewMaximized: view => grid.isViewMaximized(getView(view)), + getViewSize: view => grid.getViewSize(getView(view)), + getViewCachedVisibleSize: view => grid.getViewCachedVisibleSize(getView(view)), + moveView: (view, size, referenceView, direction) => grid.moveView(getView(view), size, getView(referenceView), direction), + resizeView: (view, size) => grid.resizeView(getView(view), size), + exitMaximizedView: () => grid.exitMaximizedView(), + }, + mainContainer: { classList: { toggle: () => { } } }, + getMaximumEditorDimensions: () => ({ width: 1200, height: 500 }), + storageService: { store: () => { } }, + _onDidChangePanelAlignment: { fire: () => { } }, + _runWithEditorResizeSyncSuspended: fn => fn(), + _layoutGrid: () => grid.layout(1200, 800), + hasFocus: () => false, + focusPart: () => { }, + }; + Object.setPrototypeOf(alignmentHost, Workbench.prototype); + + const getDimensions = () => ({ + sessionsWidth: grid.getViewSize(views.get(Parts.SESSIONS_PART)!).width, + sidePaneWidth: grid.getViewSize(views.get(Parts.EDITOR_PART)!).width, + panelHeight: grid.getViewSize(views.get(Parts.PANEL_PART)!).height, + sidebarVisible: grid.isViewVisible(views.get(Parts.SIDEBAR_PART)!), + customViewVisible: grid.isViewVisible(views.get(Parts.CUSTOM_VIEW_GRID_PART)!), + panelMaximized: grid.isViewMaximized(views.get(Parts.PANEL_PART)!), + }); + const initialDimensions = getDimensions(); + setPanelAlignment.call(alignmentHost, 'center'); + const center = getPartTopology(grid.serialize().root); + const centerDimensions = getDimensions(); + grid.maximizeView(views.get(Parts.PANEL_PART)!, [views.get(Parts.TITLEBAR_PART)!, views.get(Parts.SIDEBAR_PART)!]); + grid.exitMaximizedView(); + const restoredCenterDimensions = getDimensions(); + grid.maximizeView(views.get(Parts.PANEL_PART)!, [views.get(Parts.TITLEBAR_PART)!, views.get(Parts.SIDEBAR_PART)!]); + alignmentHost._panelMaximizedEditorState = { width: centerDimensions.sidePaneWidth }; + setPanelAlignment.call(alignmentHost, 'justify'); + const justifyDimensions = getDimensions(); + + assert.deepStrictEqual({ + center, + justify: getPartTopology(grid.serialize().root), + dimensions: { initial: initialDimensions, center: centerDimensions, restoredCenter: restoredCenterDimensions, justify: justifyDimensions }, + }, { + center: [ + Parts.TITLEBAR_PART, + [ + Parts.SIDEBAR_PART, + [Parts.SESSIONS_PART, Parts.PANEL_PART], + Parts.EDITOR_PART, + Parts.CUSTOM_VIEW_GRID_PART, + ], + ], + justify: [ + Parts.TITLEBAR_PART, + [ + Parts.SIDEBAR_PART, + [ + [Parts.SESSIONS_PART, Parts.EDITOR_PART, Parts.CUSTOM_VIEW_GRID_PART], + Parts.PANEL_PART, + ], + ], + ], + dimensions: { + initial: { ...initialDimensions }, + center: { ...initialDimensions }, + restoredCenter: { ...initialDimensions }, + justify: { ...initialDimensions }, + }, + }); + } finally { + store.dispose(); + } + }); + test('desktop restores the bottom panel height after navigating through Quick Chat', () => { const desktop = createHost({ single: true, panelHeight: 520, panelHeightOnEditorShow: 77, partVisibility: { panel: true, editor: true, auxiliaryBar: true } }); diff --git a/src/vs/workbench/api/common/extHostTimeline.ts b/src/vs/workbench/api/common/extHostTimeline.ts index c179fdfc1edc0c..6d600598e9f1c1 100644 --- a/src/vs/workbench/api/common/extHostTimeline.ts +++ b/src/vs/workbench/api/common/extHostTimeline.ts @@ -62,6 +62,7 @@ export class ExtHostTimeline implements IExtHostTimeline { registerTimelineProvider(scheme: string | string[], provider: vscode.TimelineProvider, extensionId: ExtensionIdentifier, commandConverter: CommandsConverter): IDisposable { const timelineDisposables = new DisposableStore(); + let disposed = false; const convertTimelineItem = this.convertTimelineItem(provider.id, commandConverter, timelineDisposables).bind(this); @@ -85,7 +86,7 @@ export class ExtHostTimeline implements IExtHostTimeline { } const result = await provider.provideTimeline(uri, options, token); - if (result === undefined || result === null) { + if (disposed || result === undefined || result === null) { return undefined; } @@ -99,9 +100,8 @@ export class ExtHostTimeline implements IExtHostTimeline { }; }, dispose() { - for (const sourceMap of itemsBySourceAndUriMap.values()) { - sourceMap.get(provider.id)?.clear(); - } + disposed = true; + itemsBySourceAndUriMap.delete(provider.id); disposable?.dispose(); timelineDisposables.dispose(); @@ -202,10 +202,6 @@ export class ExtHostTimeline implements IExtHostTimeline { this._providers.set(provider.id, { provider, extension }); return toDisposable(() => { - for (const sourceMap of this._itemsBySourceAndUriMap.values()) { - sourceMap.get(provider.id)?.clear(); - } - this._providers.delete(provider.id); this._proxy.$unregisterTimelineProvider(provider.id); provider.dispose(); diff --git a/src/vs/workbench/api/test/browser/extHostTimeline.test.ts b/src/vs/workbench/api/test/browser/extHostTimeline.test.ts new file mode 100644 index 00000000000000..4ec50cd03018f9 --- /dev/null +++ b/src/vs/workbench/api/test/browser/extHostTimeline.test.ts @@ -0,0 +1,113 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { DeferredPromise } from '../../../../base/common/async.js'; +import { CancellationToken } from '../../../../base/common/cancellation.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 { ExtensionIdentifier, IExtensionDescription } from '../../../../platform/extensions/common/extensions.js'; +import { CommandsConverter, ExtHostCommands, ArgumentProcessor } from '../../common/extHostCommands.js'; +import { MainThreadTimelineShape } from '../../common/extHost.protocol.js'; +import { ExtHostTimeline } from '../../common/extHostTimeline.js'; +import { TimelineItem } from '../../common/extHostTypes.js'; +import { SingleProxyRPCProtocol } from '../common/testRPCProtocol.js'; +import { TimelineOptions } from '../../../contrib/timeline/common/timeline.js'; +import { nullExtensionDescription } from '../../../services/extensions/common/extensions.js'; +import { MarshalledId } from '../../../../base/common/marshallingIds.js'; + +suite('ExtHostTimeline', function () { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('disposes cached timeline items with their provider', async function () { + let argumentProcessor!: ArgumentProcessor; + const commands = new class extends mock() { + override registerArgumentProcessor(processor: ArgumentProcessor): void { + argumentProcessor = processor; + } + }; + const proxy = new class extends mock() { + override $registerTimelineProvider(): void { } + override $unregisterTimelineProvider(): void { } + }; + const timeline = new ExtHostTimeline(SingleProxyRPCProtocol(proxy), commands); + const extension: IExtensionDescription = { + ...nullExtensionDescription, + identifier: new ExtensionIdentifier('test.timeline'), + enabledApiProposals: ['timeline'], + }; + const uri = URI.parse('file:///timeline.txt'); + const item = new TimelineItem('first item', 1); + const provider = { + id: 'test-timeline', + label: 'Test Timeline', + provideTimeline: () => ({ items: [item] }), + }; + const converter = new class extends mock() { }; + const registration = store.add(timeline.registerTimelineProvider('file', provider, extension.identifier, converter)); + const options: TimelineOptions = { cacheResults: true }; + const result = await timeline.$getTimeline(provider.id, uri, options, CancellationToken.None); + assert.ok(result); + const context = { + $mid: MarshalledId.TimelineActionContext, + handle: result.items[0].handle, + source: provider.id, + uri, + }; + assert.strictEqual(argumentProcessor.processArgument(context, extension), item); + + registration.dispose(); + const replacement = store.add(timeline.registerTimelineProvider('file', provider, extension.identifier, converter)); + assert.strictEqual(argumentProcessor.processArgument(context, extension), undefined); + replacement.dispose(); + }); + + test('does not cache an in-flight timeline result after provider disposal', async function () { + let argumentProcessor!: ArgumentProcessor; + const commands = new class extends mock() { + override registerArgumentProcessor(processor: ArgumentProcessor): void { + argumentProcessor = processor; + } + }; + const proxy = new class extends mock() { + override $registerTimelineProvider(): void { } + override $unregisterTimelineProvider(): void { } + }; + const timeline = new ExtHostTimeline(SingleProxyRPCProtocol(proxy), commands); + const extension: IExtensionDescription = { + ...nullExtensionDescription, + identifier: new ExtensionIdentifier('test.timeline'), + enabledApiProposals: ['timeline'], + }; + const uri = URI.parse('file:///timeline.txt'); + const deferredResult = new DeferredPromise<{ items: TimelineItem[] }>(); + const provider = { + id: 'test-timeline', + label: 'Test Timeline', + provideTimeline: () => deferredResult.p, + }; + const converter = new class extends mock() { }; + const registration = store.add(timeline.registerTimelineProvider('file', provider, extension.identifier, converter)); + const options: TimelineOptions = { cacheResults: true }; + const resultPromise = timeline.$getTimeline(provider.id, uri, options, CancellationToken.None); + + registration.dispose(); + const replacement = store.add(timeline.registerTimelineProvider('file', { + ...provider, + provideTimeline: () => ({ items: [] }), + }, extension.identifier, converter)); + deferredResult.complete({ items: [new TimelineItem('stale item', 1)] }); + + assert.strictEqual(await resultPromise, undefined); + assert.strictEqual(argumentProcessor.processArgument({ + $mid: MarshalledId.TimelineActionContext, + handle: `${provider.id}|1`, + source: provider.id, + uri, + }, extension), undefined); + replacement.dispose(); + }); +}); diff --git a/src/vs/workbench/common/notifications.ts b/src/vs/workbench/common/notifications.ts index c01b52985a640e..367857162013c5 100644 --- a/src/vs/workbench/common/notifications.ts +++ b/src/vs/workbench/common/notifications.ts @@ -146,6 +146,8 @@ export class NotificationHandle extends Disposable implements INotificationHandl }); } + get visible(): boolean { return this.item.visible; } + get progress(): INotificationProgress { return this.item.progress; } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts index 68b81e06066f5e..6fa84dee233c6b 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts @@ -13,6 +13,7 @@ import { ChatSessionArchiveActionWording, getChatSessionArchiveActionWording } f import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { ICodexContinuationService } from '../../../../services/agentHost/browser/codexContinuationService.js'; import { IWorkbenchEnvironmentService } from '../../../../services/environment/common/environmentService.js'; import { AccessibilityVerbositySettingId } from '../../../accessibility/browser/accessibilityConfiguration.js'; import { INLINE_CHAT_ID } from '../../../inlineChat/common/inlineChat.js'; @@ -67,9 +68,13 @@ export class AgentChatAccessibilityHelp implements IAccessibleViewImplementation } } -export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'quickChat' | 'editsView' | 'agentView', keybindingService: IKeybindingService, supportsFileReferences: boolean, isSessionsWindow: boolean = false, stickyPromptHeaderShown: boolean = false, sessionStatusPillsSupported: boolean = type === 'panelChat' || type === 'agentView', sessionArchiveNudgeShown: boolean = false, sessionArchiveActionWording = ChatSessionArchiveActionWording.Archive): string { +export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'quickChat' | 'editsView' | 'agentView', keybindingService: IKeybindingService, supportsFileReferences: boolean, isSessionsWindow: boolean = false, stickyPromptHeaderShown: boolean = false, sessionStatusPillsSupported: boolean = type === 'panelChat' || type === 'agentView', sessionArchiveNudgeShown: boolean = false, sessionArchiveActionWording = ChatSessionArchiveActionWording.Archive, codexContinuationShown: boolean = false): string { const content = []; + if (codexContinuationShown && type !== 'inlineChat' && type !== 'quickChat') { + content.push(localize('chat.codexContinuation', "A ChatGPT limit suggestion may offer Continue with Copilot. If the eligible Codex session is not active, the guide highlights its row in the Sessions list. Activate Open Session in the guide or open the row using the list's usual keyboard shortcut: by default, Command+Down Arrow on macOS or Enter on Windows and Linux. The next step opens its model picker with a matching Copilot model in the search. Activate Use Copilot in the guide or use Up and Down Arrow and Enter in the picker to select the Copilot model. Use Tab or Shift+Tab to reach the guide's buttons and Enter or Space to activate them. An already-active eligible session goes directly to the picker. End Tour or Escape cancels the remaining guidance; it does not undo a model selection.")); + } if (isSessionsWindow) { + content.push(localize('chat.missionControl.help', "Connect to Mission Control Environment opens a list of existing user-local hosts. Cached hosts are shown while the list refreshes in the background without clearing your search. Use Up and Down Arrow to choose a host and Enter to connect, or Escape to cancel. The owning application must be running; connecting does not create replacement compute. Passive connections are read-only, and local sessions continue using the local host.")); content.push(localize('chat.sessionPreparation', "While a session is being prepared, a progress message appears in the transcript. Use Tab or Shift+Tab to reach Show Log, when available, and press Enter or Space to open the output log. Use Stop to cancel preparation. The chat input and attachment controls are disabled until preparation finishes.")); } if (!isSessionsWindow && type !== 'inlineChat' && type !== 'quickChat') { @@ -142,6 +147,8 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'qui content.push(localize('chat.inspectResponse', 'In the input box, inspect the last response in the accessible view{0}. Thinking content is included in order by default.', '')); content.push(localize('chat.inspectResponseThinkingToggle', 'To include or exclude thinking content in the accessible view, run the Toggle Thinking Content in Accessible View command from the Command Palette.')); content.push(localize('chat.completedResponseDisclosure', 'When completed response collapsing is enabled, the final response remains visible while earlier work is collapsed. Use Tab to focus the work disclosure and press Enter or Space to show or hide that work. With persistent progress enabled, a separate file changes button shows lines added and deleted when the collapsed work includes edits. Activate it to open those file changes without expanding the work.')); + content.push(localize('chat.generatedImages', "Image generation appears separately from thinking and tool-call groups. Copilot and Codex agent sessions share this presentation. Completed steps collapse only before the first image-generation tool, keeping image tools in their original position. While generation runs, expand the tool dropdown to inspect the submitted prompt. Its title says Using the image model to generate an image when the model is available, and does not shimmer. The image model can differ from the selected conversation model. A compact decorative band of shifting glyphs remains visible below the dropdown and reveals a single image when its bytes are available. Overlapping image-generation attempts each keep their own input dropdown and share one animation while any attempt is running. The animation does not indicate a percentage complete and stays still when reduced motion is enabled or a high contrast theme is active. The reveal is skipped in those modes and when reopening a chat. Only the persistent progress line rotates through painting-themed phrases, without repeatedly announcing them. These phrases are decorative, not individual generation stages. Use Tab to focus the dropdown and Enter or Space to expand or collapse it. Successful generation shows the Generated image dropdown, including the image model when available, with a single image below it even when collapsed. If generation fails, the Generated image failed row can be expanded to inspect the error. Completed and failed image tools keep their final status when you reopen the chat. Image previews start loading as soon as generation succeeds, even if the response is still in progress. A preview is marked as busy while its bytes load. Unable to load image indicates a problem loading the preview, not a failed generation. Use Tab or Shift+Tab to focus an image and Enter to open it. Use the Save action to the right of the image to save generated images.")); + content.push(localize('chat.generatedImageCancellation', "Stopping image generation shows Image generation cancelled, not a generation error. Cancelled image tools keep their cancelled status when you reopen the chat.")); if (type === 'agentView') { content.push(localize('chat.systemNotificationDisclosure', 'Some session status messages have additional details. Use Tab to focus the status message and press Enter or Space to show or hide its details.')); } @@ -262,7 +269,7 @@ export function getChatAccessibilityHelpProvider(accessor: ServicesAccessor, edi const cachedPosition = inputEditor.getPosition(); inputEditor.getSupportedActions(); const isInlineChat = isIChatResourceViewContext(widget.viewContext) && widget.viewContext.isInlineChat; - const helpText = getAccessibilityHelpText(type, keybindingService, widget.supportsFileReferences, environmentService.isSessionsWindow, isStickyPromptHeaderShown(widget, configurationService), !widget.rendersInputOnTop && !isInlineChat, widget.inputPart.hasSessionArchiveNudge, getChatSessionArchiveActionWording(configurationService)); + const helpText = getAccessibilityHelpText(type, keybindingService, widget.supportsFileReferences, environmentService.isSessionsWindow, isStickyPromptHeaderShown(widget, configurationService), !widget.rendersInputOnTop && !isInlineChat, widget.inputPart.hasSessionArchiveNudge, getChatSessionArchiveActionWording(configurationService), accessor.get(ICodexContinuationService).isVisible.get()); return new AccessibleContentProvider( type === 'panelChat' ? AccessibleViewProviderId.PanelChat : type === 'inlineChat' ? AccessibleViewProviderId.InlineChat : type === 'agentView' ? AccessibleViewProviderId.AgentChat : AccessibleViewProviderId.QuickChat, { type: AccessibleViewType.Help }, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostByokLmHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostByokLmHandler.ts index 29c36d99b7d268..4388a93a39e12a 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostByokLmHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostByokLmHandler.ts @@ -293,7 +293,11 @@ export class AgentHostByokLmHandler extends Disposable implements IAgentHostByok content: [{ type: 'tool_result', toolCallId: item.callId, - value: [{ type: 'text', value: item.output }], + value: item.content + ? item.content.map(part => part.type === 'text' + ? { type: 'text' as const, value: part.text } + : { type: 'data' as const, mimeType: part.mimeType, data: decodeBase64(part.data) }) + : [{ type: 'text', value: item.output }], }], }; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts index 36dc7eda9456ec..bb4e619d0f8b00 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts @@ -527,7 +527,7 @@ export class AgentHostChatInputPicker extends Disposable { return; } - const localBackend = toAgentHostBackendSessionUri(sessionResource); + const localBackend = toAgentHostBackendSessionUri(sessionResource, this._connectionsService); if (localBackend && isUntitledChatSession(sessionResource) && !provisionalBackend) { this._subRef.clear(); if (!this._initialResolved || this._initialResolved.sessionResource.toString() !== sessionResource.toString()) { @@ -829,7 +829,7 @@ export class AgentHostChatInputPicker extends Disposable { if (!bound) { return undefined; } - const backendSession = toAgentHostBackendSessionUri(sessionResource); + const backendSession = toAgentHostBackendSessionUri(sessionResource, this._connectionsService); if (!backendSession) { return undefined; } @@ -1173,7 +1173,7 @@ export class AgentHostChatInputPicker extends Disposable { const partial = { [ctx.key]: normalizedValue }; const nextConfig = filterSessionConfigValues(ctx.configSchema, { ...this._readCurrentValues(), ...partial }); - if (isUntitledChatSession(sessionResource) && toAgentHostBackendSessionUri(sessionResource)) { + if (isUntitledChatSession(sessionResource) && toAgentHostBackendSessionUri(sessionResource, this._connectionsService)) { // Route through the provisional service so the workbench-owned // config cache is updated synchronously. `tryRebind` reads from // that cache, so a Send racing with this dispatch picks up the diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts index c413c19d10fded..fab92fbfefdb0c 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts @@ -16,7 +16,7 @@ import { IAgentConnection } from '../../../../../../platform/agentHost/common/ag import { IAgentHostResourceUriMapper } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { AMBIENT_AGENT_HOST_AUTHORITY, IAgentHostConnectionsService, IAgentHostSessionResolution } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { getEffectiveAgents } from '../../../../../../platform/agentHost/common/customAgents.js'; -import { readMcpServerDisplayName, readMcpServerSource } from '../../../../../../platform/agentHost/common/meta/mcpCustomizationMeta.js'; +import { readMcpServerControllingSetting, readMcpServerDisplayName, readMcpServerSource, readMcpServerSourcePlugin } from '../../../../../../platform/agentHost/common/meta/mcpCustomizationMeta.js'; import { getCustomizationDisabledReason, isCustomizationEnabled, withCustomizationEnablement } from '../../../../../../platform/agentHost/common/customizationEnablement.js'; import { type IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ActionType } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; @@ -251,13 +251,16 @@ export abstract class AbstractAgentHostCustomizationService extends Disposable i return []; } return getPresentableMcpServerCustomizations(target.customizations) - .map(({ server, plugin }): IAgentHostMcpServer => { + .map(({ server, plugin, isTopLevel }): IAgentHostMcpServer => { const source = URI.parse(server.uri); return { id: this._scopedMcpServerId(sessionResource, server.id), name: server.name, displayName: readMcpServerDisplayName(server), source: readMcpServerSource(server), + sourcePluginName: readMcpServerSourcePlugin(server), + hostConfiguration: isTopLevel ? getHostMcpServerConfiguration(target.rootConfig, server.name) : undefined, + controllingSettingId: readMcpServerControllingSetting(server), enabled: isCustomizationEnabled(server) && (!plugin || isCustomizationEnabled(plugin)), enablement: server.enablement, isPluginProvided: plugin !== undefined, @@ -486,6 +489,19 @@ export function getPresentableMcpServerCustomizations(customizations: readonly C return entries.filter(entry => entry.isTopLevel || !topLevelNames.has(entry.server.name)); } +/** + * Reads the definition the agent host's own MCP server configuration holds for `serverName`. Such + * servers have no configuration file, so the runtime reports no source for them. + */ +function getHostMcpServerConfiguration(rootConfig: RootConfigState | undefined, serverName: string): IMcpServerConfiguration | undefined { + const servers = rootConfig?.values[AgentHostMcpServersConfigKey]; + if (!servers || typeof servers !== 'object' || Array.isArray(servers) || !Object.hasOwn(servers, serverName)) { + return undefined; + } + const configuration: unknown = (servers as Record)[serverName]; + return configuration && typeof configuration === 'object' && !Array.isArray(configuration) ? configuration as IMcpServerConfiguration : undefined; +} + function hasSessionSnapshot(subscription: IAgentSubscription): boolean { return subscription.value !== undefined && !(subscription.value instanceof Error); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostGenericConfigChips.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostGenericConfigChips.ts index ac9d42a64bd5d5..d36c99391f9d12 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostGenericConfigChips.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostGenericConfigChips.ts @@ -93,7 +93,7 @@ export class AgentHostGenericConfigChips extends Disposable { return; } - const localBackend = toAgentHostBackendSessionUri(sessionResource); + const localBackend = toAgentHostBackendSessionUri(sessionResource, this._connectionsService); if (localBackend && isUntitledChatSession(sessionResource) && !provisionalBackend) { this._subRef.clear(); if (!this._initialResolved || this._initialResolved.sessionResource.toString() !== sessionResource.toString()) { @@ -183,7 +183,7 @@ export class AgentHostGenericConfigChips extends Disposable { } const entries = this._readSchemaProperties(); const sessionResource = this._widget.viewModel?.sessionResource; - const isStartedSession = !!sessionResource && !(isUntitledChatSession(sessionResource) && toAgentHostBackendSessionUri(sessionResource)); + const isStartedSession = !!sessionResource && !(isUntitledChatSession(sessionResource) && toAgentHostBackendSessionUri(sessionResource, this._connectionsService)); const desired = new Set(); if (entries) { const configSchema = { type: 'object' as const, properties: Object.fromEntries(entries) }; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostModeSynchronizer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostModeSynchronizer.ts index 1400e424bee137..95dca7a7dd7747 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostModeSynchronizer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostModeSynchronizer.ts @@ -6,7 +6,7 @@ import { Disposable, DisposableMap, DisposableStore } from '../../../../../../base/common/lifecycle.js'; import { autorun } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; -import { AgentSession } from '../../../../../../platform/agentHost/common/agentService.js'; +import { IAgentHostConnectionsService } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { fromAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { agentHostAgentPickerStorageKey } from '../../../../../../platform/agentHost/common/customAgents.js'; import { isUntitledChatSession } from '../../../common/model/chatUri.js'; @@ -32,6 +32,7 @@ export class AgentHostModeSynchronizer extends Disposable implements IWorkbenchC @IAgentHostUntitledProvisionalSessionService private readonly _provisionalSessionService: IAgentHostUntitledProvisionalSessionService, @IStorageService private readonly _storageService: IStorageService, @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, + @IAgentHostConnectionsService private readonly _connectionsService: IAgentHostConnectionsService, ) { super(); if (environmentService.isSessionsWindow) { @@ -180,8 +181,7 @@ export class AgentHostModeSynchronizer extends Disposable implements IWorkbenchC return undefined; } - const provider = sessionResource.scheme.substring(AGENT_HOST_SESSION_SCHEME_PREFIX.length); - return provider ? AgentSession.uri(provider, sessionResource.path.substring(1)) : undefined; + return this._connectionsService.resolveSessionResourceIdentity(sessionResource)?.backendSession; } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 5ab58c0722f061..0928a9d56514c0 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -26,6 +26,9 @@ import { MicrotaskDelay } from '../../../../../../base/common/symbols.js'; import { Mutable } from '../../../../../../base/common/types.js'; import { URI } from '../../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; +import { newAgentHostSessionUri } from '../../../../../../platform/agentHost/common/agentHostSessionIdentity.js'; +import { getAgentHostChatId } from '../../../../../../platform/agentHost/common/agentHostChatIdentity.js'; +import { IAgentHostConnectionsService } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { IPosition } from '../../../../../../editor/common/core/position.js'; import type { IRange } from '../../../../../../editor/common/core/range.js'; import { isLocation, type Location } from '../../../../../../editor/common/languages.js'; @@ -1237,6 +1240,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC * it (see {@link _releaseChatSessionSubscriptions} / {@link _hasOtherSessionHold}). */ private readonly _hydratingChatSessions = new Map(); + private _sessionIdentityRefresh: Promise | undefined; /** * Whether the `hideAutoExplainability` experiment suppresses Auto's routing @@ -1281,6 +1285,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC @IAgentHostCustomizationService private readonly _customizationService: IAgentHostCustomizationService, @ITelemetryService private readonly _telemetryService: ITelemetryService, @IWorkbenchAssignmentService assignmentService: IWorkbenchAssignmentService, + @IAgentHostConnectionsService private readonly _connectionsService: IAgentHostConnectionsService, ) { super(); this._config = config; @@ -1526,13 +1531,17 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // For new sessions, defer backend session creation until the first request // arrives so the user-selected model is available. The chat resource still // carries the raw session id that will be used when createSession runs. - const resolvedSession = this._resolveSessionUri(sessionResource); + let resolvedSession = this._resolveSessionUri(sessionResource); let chatURI: string | undefined; // The point of this is to check with the session provider or controller // whether this session resource represents a new session that hasn't yet // been created on the backend. const isNewSession = this._isNewSessionResource(sessionResource); + if (!isNewSession && !this._provisionalService.get(sessionResource) + && !this._resolveAdvertisedSessionUri(sessionResource)) { + resolvedSession = await this._discoverSessionIdentity(sessionResource, token); + } this._logService.trace(`[AgentHost] provideChatSessionContent start: ${resolvedSession.toString()} (isNewSession=${isNewSession})`); const history: IChatSessionHistoryItem[] = []; let initialProgress: IChatProgress[] | undefined; @@ -1606,7 +1615,6 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC this._chatErrorContext(), this._config.connection.initializeResult.get()?.terminalCommandPrefix, this._config.connection.resourceUris, - this._config.provider, turn => this._getTurnErrorDetails(turn, allowTurnResume), readAgentContextUsage(sessionState, sessionState.turns.at(-1)?.usage), )); @@ -1646,7 +1654,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC ...(isMessageRequestHiddenFromTranscript(sessionState.activeTurn.message) ? { isRequestHidden: true } : {}), isSystemInitiated: sessionState.activeTurn.message.origin.kind === MessageKind.SystemNotification, requestSource: messageToRequestSource(sessionState.activeTurn.message), - origin: messageToRequestOrigin(resolvedSession, sessionState.activeTurn.message, this._config.agentId, this._config.provider), + origin: messageToRequestOrigin(resolvedSession, sessionState.activeTurn.message, this._config.agentId, this._config.connectionAuthority), }); history.push({ type: 'response', @@ -2399,7 +2407,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return explicitChatUri; } if (sessionResource.fragment) { - const match = state.chats.find(summary => parseChatUri(summary.resource)?.chatId === sessionResource.fragment); + const match = state.chats.find(summary => getAgentHostChatId(summary.resource) === sessionResource.fragment); if (!match) { throw new Error(`Cannot resolve chat '${sessionResource.fragment}' from session state for ${sessionResource.toString()}`); } @@ -2548,7 +2556,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const allowTurnResume = !this._isChatReadOnly(sessionStr, chatURI); chatSession.updateHistory(turnsToHistory(backendSession, state.turns, this._config.agentId, this._config.connectionAuthority, lookup, this._chatErrorContext(), this._config.connection.initializeResult.get()?.terminalCommandPrefix, - this._config.connection.resourceUris, this._config.provider, turn => this._getTurnErrorDetails(turn, allowTurnResume), + this._config.connection.resourceUris, turn => this._getTurnErrorDetails(turn, allowTurnResume), readAgentContextUsage(state, state.turns.at(-1)?.usage))); }; let previousContextUsage = currentState ? readAgentContextUsage(currentState, currentState.turns.at(-1)?.usage) : undefined; @@ -2656,7 +2664,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC timestamp: parseTimestamp(activeTurn.startedAt), isTerminalRequest: isTerminalCommandPrompt(activeTurn.message.text, this._config.connection.initializeResult.get()?.terminalCommandPrefix), resume: resumedTurn, - origin: messageToRequestOrigin(backendSession, activeTurn.message, this._config.agentId, this._config.provider), + origin: messageToRequestOrigin(backendSession, activeTurn.message, this._config.agentId, this._config.connectionAuthority), }, ); @@ -5769,14 +5777,71 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC ); } - /** Maps a UI session resource to a backend provider URI. */ + private async _discoverSessionIdentity(sessionResource: URI, token: CancellationToken): Promise { + if (!this._sessionIdentityRefresh) { + const refresh = this._loadSessionIdentities(sessionResource).finally(() => { + if (this._sessionIdentityRefresh === refresh) { + this._sessionIdentityRefresh = undefined; + } + }); + this._sessionIdentityRefresh = refresh; + } + await raceCancellationError(this._sessionIdentityRefresh, token); + const advertised = this._resolveAdvertisedSessionUri(sessionResource); + if (!advertised) { + throw new Error(`Session identity was not advertised by its agent host: ${sessionResource.toString()}`); + } + return advertised; + } + + private async _loadSessionIdentities(sessionResource: URI): Promise { + const authority = this._connectionsService.resolveSessionResourceIdentity(sessionResource)?.connectionAuthority + ?? this._config.connectionAuthority; + for (const metadata of await this._config.connection.listSessions()) { + this._connectionsService.registerSessionResource(metadata.session, authority, metadata.provider); + } + } + + /** Resolves a known backend identity or a not-yet-created session address. */ private _resolveSessionUri(sessionResource: URI): URI { const provisionalSession = this._provisionalService.get(sessionResource); if (provisionalSession) { return provisionalSession; } const rawId = sessionResource.path.substring(1); - return AgentSession.uri(this._config.backendSessionScheme ?? this._config.provider, rawId); + const advertised = this._resolveAdvertisedSessionUri(sessionResource); + if (advertised) { + return advertised; + } + if (!this._isNewSessionResource(sessionResource)) { + return AgentSession.uri(this._config.backendSessionScheme ?? this._config.provider, rawId); + } + return this._config.backendSessionScheme + ? AgentSession.uri(this._config.backendSessionScheme, rawId) + : newAgentHostSessionUri(this._config.provider, rawId, this._config.connection.initializeResult.get()); + } + + private _resolveAdvertisedSessionUri(sessionResource: URI): URI | undefined { + const identity = this._connectionsService.resolveSessionResourceIdentity(sessionResource); + if (identity?.backendSessionIsAdvertised) { + return identity.backendSession; + } + const query = sessionResource.query.split('&').filter(part => !part.startsWith(`${CHAT_SUBAGENT_RESOURCE_QUERY_PARAM}=`)).join('&'); + const parent = sessionResource.with({ query, fragment: '' }); + const parentIdentity = this._connectionsService.resolveSessionResourceIdentity(parent); + if (parentIdentity?.backendSessionIsAdvertised) { + return parentIdentity.backendSession; + } + const authority = identity?.connectionAuthority ?? this._config.connectionAuthority; + if (this._connectionsService.findSessionResource(parent, authority)) { + return parent; + } + const legacy = AgentSession.uri(this._config.backendSessionScheme ?? this._config.provider, AgentSession.id(parent)); + if (parent.authority || parent.query || legacy.scheme === 'ahp-session' + || (parent.scheme !== this._config.sessionType && parent.scheme !== legacy.scheme)) { + return undefined; + } + return this._connectionsService.findSessionResource(legacy, authority) ? legacy : undefined; } private _isNewSessionResource(sessionResource: URI): boolean { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts index f899480bfcdefb..beabfe9d7e2ab1 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts @@ -8,10 +8,11 @@ import { Event } from '../../../../../../base/common/event.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; +import { getAgentHostChatId } from '../../../../../../platform/agentHost/common/agentHostChatIdentity.js'; import { AgentSession } from '../../../../../../platform/agentHost/common/agentService.js'; import { withEphemeralSessionMeta } from '../../../../../../platform/agentHost/common/meta/agentEphemeralSessionMeta.js'; import { ChatInteractivity, type ChangesSummary, type SessionChatSummary } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; -import { ChatOriginKind, isDefaultChatUri, parseChatUri, SessionStatus, readSessionEhcliAdoptable, SESSION_META_EHCLI_ADOPTABLE_KEY, type SessionSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { ChatOriginKind, isDefaultChatUri, SessionStatus, readSessionEhcliAdoptable, SESSION_META_EHCLI_ADOPTABLE_KEY, type SessionSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { IChatService } from '../../../common/chatService/chatService.js'; import { ChatSessionStatus, IChatNewSessionRequest, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta } from '../../../common/chatSessionsService.js'; @@ -100,7 +101,7 @@ export class AgentHostSessionListController extends Disposable implements IChatS const rawId = generateUuid(); this._sessionListStore.addPendingNewSession(this._provider, rawId); const now = Date.now(); - const item = this._makeItem(rawId, { + let item = this._makeItem(rawId, { title: request.prompt.trim(), status: SessionStatus.InProgress, createdAt: now, @@ -135,7 +136,30 @@ export class AgentHostSessionListController extends Disposable implements IChatS // untitled chat-input resource to the freshly-minted real resource so // the provisional `getOrCreate` for the real resource seeds it. this._importConversationStore.rename(request.untitledResource, item.resource); - await this._provisional.tryRebind(request.untitledResource, item.resource, this._provider); + const rebound = await this._provisional.tryRebind(request.untitledResource, item.resource, this._provider); + const previousResource = item.resource; + // The final item is not published yet; retired allocations must not be reused. + const nextResource = previousResource.with({ path: rebound?.path ?? `/${generateUuid()}` }); + if (nextResource.path !== previousResource.path) { + const pending = this._sessionListStore.isPendingNewSession(this._provider, rawId); + this._sessionListStore.clearPendingNewSession(this._provider, rawId); + if (pending || !rebound) { + this._sessionListStore.addPendingNewSession(this._provider, AgentSession.id(nextResource)); + } + const currentDirectory = rebound + ? this._provisional.getProvisionalWorkingDirectories(nextResource)?.[0] + : this._newSessionFolderService.getFolder(request.untitledResource) + ?? this._newSessionFolderService.getDefaultFolder() + ?? this._workspaceContextService.getWorkspace().folders[0]?.uri; + if (currentDirectory) { + this._newSessionFolderService.setFolder(nextResource, currentDirectory); + } + this._importConversationStore.rename(previousResource, nextResource); + if (metadata) { + this._provisional.setSessionCreationMetadata(nextResource, metadata); + } + item = { ...item, resource: nextResource }; + } } return item; @@ -149,7 +173,7 @@ export class AgentHostSessionListController extends Disposable implements IChatS const rawId = AgentSession.id(resource); if (resource.fragment) { const entry = this._sessionListStore.getSessions(this._provider).find(entry => entry.rawId === rawId); - const chat = entry?.summary.chats?.find(chat => parseChatUri(chat.resource)?.chatId === resource.fragment); + const chat = entry?.summary.chats?.find(chat => getAgentHostChatId(chat.resource) === resource.fragment); if (!chat) { throw new Error(`Cannot resolve chat '${resource.fragment}' in session '${rawId}'`); } @@ -299,11 +323,7 @@ export class AgentHostSessionListController extends Disposable implements IChatS } private _chatResource(rawId: string, chat: SessionChatSummary): URI { - const parsed = parseChatUri(chat.resource); - if (!parsed) { - throw new Error(`Invalid chat URI '${chat.resource}' in session '${rawId}'`); - } - return this._resource(rawId).with({ fragment: parsed.chatId }); + return this._resource(rawId).with({ fragment: getAgentHostChatId(chat.resource) }); } private _isDefaultChat(summary: SessionSummary, chat: SessionChatSummary): boolean { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts index 35df856739efc1..28f0dd9b68ded6 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts @@ -9,7 +9,7 @@ import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { extUriBiasedIgnorePathCase } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { AgentSession, type IAgentSessionMetadata } from '../../../../../../platform/agentHost/common/agentService.js'; -import { IAgentHostSessionSchemeAlias } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { IAgentHostConnectionsService, IAgentHostSessionSchemeAlias } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { ActionType, type IIsArchivedChangedAction, type IIsReadChangedAction, type INotification, type SessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { readSessionMatchesByProjectRoot, readSessionMultiRootMetadata, SessionStatus, type SessionSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IWorkspaceContextService, type IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; @@ -99,9 +99,10 @@ export class AgentHostSessionListStore extends Disposable { constructor( private readonly _connection: IAgentHostSessionListConnection, - private readonly _options: { readonly filterToWorkspace?: boolean; readonly sessionSchemeAlias?: IAgentHostSessionSchemeAlias } = {}, + private readonly _options: { readonly filterToWorkspace?: boolean; readonly sessionSchemeAlias?: IAgentHostSessionSchemeAlias; readonly connectionAuthority?: string } = {}, @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, @ILogService private readonly _logService: ILogService, + @IAgentHostConnectionsService private readonly _connectionsService: IAgentHostConnectionsService, ) { super(); @@ -169,13 +170,19 @@ export class AgentHostSessionListStore extends Disposable { this._mutationGeneration++; } - private _providerForSession(session: URI | string): string | undefined { + private _providerForSession(session: URI | string, provider?: string): string | undefined { + const resource = typeof session === 'string' ? session : session.toString(); + provider ??= [...this._entries.values()].find(entry => entry.summary.resource === resource)?.provider; const scheme = AgentSession.provider(session); const alias = this._options.sessionSchemeAlias; - return alias && scheme === alias.backend ? alias.ui : scheme; + return provider ?? (alias && URI.parse(resource).scheme === alias.backend ? alias.ui : scheme); } private _sessionUri(provider: string, rawId: string): URI { + const entry = this._entries.get(this._key(provider, rawId)); + if (entry) { + return URI.parse(entry.summary.resource); + } const alias = this._options.sessionSchemeAlias; return AgentSession.uri(alias && provider === alias.ui ? alias.backend : provider, rawId); } @@ -386,12 +393,13 @@ export class AgentHostSessionListStore extends Disposable { } private _makeEntryFromMetadata(session: IAgentSessionMetadata): IAgentHostSessionListEntry | undefined { - const provider = this._providerForSession(session.session); + const provider = this._providerForSession(session.session, session.provider); if (!provider) { return undefined; } const rawId = AgentSession.id(session.session); + this._connectionsService.registerSessionResource(session.session, this._options.connectionAuthority, provider); return { provider, @@ -427,10 +435,11 @@ export class AgentHostSessionListStore extends Disposable { } private _makeEntryFromSummary(summary: SessionSummary): IAgentHostSessionListEntry | undefined { - const provider = summary.provider || this._providerForSession(summary.resource); + const provider = this._providerForSession(summary.resource, summary.provider); if (!provider) { return undefined; } + this._connectionsService.registerSessionResource(URI.parse(summary.resource), this._options.connectionAuthority, provider); return { provider, rawId: AgentSession.id(summary.resource), @@ -526,7 +535,7 @@ export class AgentHostSessionListStore extends Disposable { return { provider: entry.provider, rawId: entry.rawId, - session: AgentSession.uri(entry.provider, entry.rawId), + session: URI.parse(entry.summary.resource), }; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionUri.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionUri.ts index 3dceb2dabdbdee..468f544b2a1b89 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionUri.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionUri.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { URI } from '../../../../../../base/common/uri.js'; -import { AMBIENT_AGENT_HOST_AUTHORITY, IAgentHostConnectionsService, IAgentHostSessionResolution } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { AMBIENT_AGENT_HOST_AUTHORITY, IAgentHostConnectionsService, IAgentHostSessionResolution, LOCAL_AGENT_HOST_SCHEME_PREFIX } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; /** Preserves local provisional resources while resolving all other sessions through their owning host. */ export function resolveAgentHostChatSession(sessionResource: URI, provisionalBackend: URI | undefined, connectionsService: IAgentHostConnectionsService): IAgentHostSessionResolution | undefined { @@ -14,16 +14,14 @@ export function resolveAgentHostChatSession(sessionResource: URI, provisionalBac : resolution; } -export function toAgentHostBackendSessionUri(sessionResource: URI): URI | undefined { - const scheme = sessionResource.scheme; - const prefix = 'agent-host-'; - if (!scheme.startsWith(prefix)) { - return undefined; - } - const provider = scheme.substring(prefix.length); - if (!provider) { - return undefined; - } - const rawId = sessionResource.path.replace(/^\//, ''); - return URI.from({ scheme: provider, path: `/${rawId}` }); +export function getLocalAgentHostSessionProvider(sessionResource: URI): string | undefined { + return sessionResource.scheme.startsWith(LOCAL_AGENT_HOST_SCHEME_PREFIX) + ? sessionResource.scheme.substring(LOCAL_AGENT_HOST_SCHEME_PREFIX.length) || undefined + : undefined; +} + +export function toAgentHostBackendSessionUri(sessionResource: URI, connectionsService: IAgentHostConnectionsService): URI | undefined { + return getLocalAgentHostSessionProvider(sessionResource) + ? connectionsService.resolveSessionResourceIdentity(sessionResource)?.backendSession + : undefined; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts index e12457e3bfcc1c..91d7a78bc921a6 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts @@ -13,10 +13,10 @@ * * Resource identities: * - chat UI resource: `agent-host-PROVIDER:/untitled-` before first Send. - * - backend resource: an opaque `PROVIDER:/` for provisional state. + * - backend resource: the negotiated host resource for provisional state. * - real chat resource: `agent-host-PROVIDER:/` after * `chatServiceImpl.acceptInput` calls `createNewChatSessionItem`. - * - real backend resource: `PROVIDER:/` after `tryRebind`. + * - real backend resource: a new negotiated host resource after `tryRebind`. * * Required flow: * 1. `AgentHostChatInputPicker` calls `getOrCreate(untitled, provider, cwd)`. @@ -57,6 +57,7 @@ import { autorun } from '../../../../../../base/common/observable.js'; import { isEqual } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; +import { newAgentHostSessionUri } from '../../../../../../platform/agentHost/common/agentHostSessionIdentity.js'; import { IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; import { IAgentHostConnectionsService, IAgentHostSessionResolution } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { KNOWN_MODE_VALUES, SessionConfigKey } from '../../../../../../platform/agentHost/common/sessionConfigKeys.js'; @@ -642,7 +643,7 @@ export class AgentHostUntitledProvisionalSessionService extends Disposable imple } private _newProvisionalUri(provider: string): URI { - return URI.from({ scheme: provider, path: `/${generateUuid()}` }); + return newAgentHostSessionUri(provider, generateUuid(), this._agentHostService.initializeResult.get()); } /** @@ -767,7 +768,7 @@ export class AgentHostUntitledProvisionalSessionService extends Disposable imple return undefined; } - const newBackendSession = this._toBackendUri(newSessionResource, provider); + let newBackendSession = this._toBackendUri(newSessionResource, provider); // Imports materialize eagerly, so carry their history and model into the rebound session. const imported = this._importConversationStore.take(newSessionResource); @@ -822,6 +823,10 @@ export class AgentHostUntitledProvisionalSessionService extends Disposable imple this._restoreImportedConversation(newSessionResource, imported); throw new Error(`Cannot safely retry rebound session ${newBackendSession.toString()} until its stale candidate is retired`); } + if (newBackendSession.scheme === 'ahp-session') { + // Disposal permanently retires a standard session identity. + newBackendSession = this._toBackendUri(newSessionResource.with({ path: `/${generateUuid()}` }), provider); + } continue; } @@ -831,7 +836,15 @@ export class AgentHostUntitledProvisionalSessionService extends Disposable imple newEntry.usesWorkspaceRootSet = oldEntry.usesWorkspaceRootSet; this._updateActiveClientScope(newEntry); newEntry.generation = { backendSession: created, workingDirectory: targetWorkingDirectory, workingDirectories: targetWorkingDirectories }; - this._entries.set(newSessionResource, newEntry); + const boundResource = newSessionResource.with({ path: created.path }); + this._entries.set(boundResource, newEntry); + if (!isEqual(boundResource, newSessionResource)) { + const metadata = this._sessionCreationMetadata.get(newSessionResource); + if (metadata) { + this._sessionCreationMetadata.set(boundResource, metadata); + this._sessionCreationMetadata.delete(newSessionResource); + } + } this._publishActiveClient(newEntry); this._entries.delete(oldSessionResource); oldEntry.disposed = true; @@ -840,7 +853,7 @@ export class AgentHostUntitledProvisionalSessionService extends Disposable imple this._resolvedConfigRequestSeq.delete(oldSessionResource); this._rebound.add(oldSessionResource); // Notify only the real resource; notifying the old URI can recreate an orphan while the widget still uses it. - this._onDidChange.fire(newSessionResource); + this._onDidChange.fire(boundResource); if (oldGeneration) { // The temporary generation is in-memory only, so disposal is best-effort. @@ -962,7 +975,7 @@ export class AgentHostUntitledProvisionalSessionService extends Disposable imple */ private _toBackendUri(sessionResource: URI, provider: string): URI { const rawId = sessionResource.path.replace(/^\//, ''); - return URI.from({ scheme: provider, path: `/${rawId}` }); + return newAgentHostSessionUri(provider, rawId, this._agentHostService.initializeResult.get()); } getResolvedConfig(sessionResource: URI): ResolveSessionConfigResult | undefined { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentSessionSettings.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentSessionSettings.contribution.ts index fd17c0146bc5bc..4b9e7eb78482a3 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentSessionSettings.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentSessionSettings.contribution.ts @@ -7,6 +7,7 @@ import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { localize, localize2 } from '../../../../../../nls.js'; import { Action2, MenuId, registerAction2 } from '../../../../../../platform/actions/common/actions.js'; import { AGENT_HOST_ENABLED_CONTEXT_KEY } from '../../../../../../platform/agentHost/common/agentHostEnablementService.js'; +import { IAgentHostConnectionsService } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { ContextKeyExpr } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; import { IInstantiationService, ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; @@ -99,7 +100,7 @@ registerAction2(class OpenAgentSessionSettingsAction extends Action2 { if (!session) { return; } - const backendSession = toAgentHostBackendSessionUri(session.resource); + const backendSession = toAgentHostBackendSessionUri(session.resource, accessor.get(IAgentHostConnectionsService)); if (!backendSession) { return; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/applyAgentHostSessionConfig.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/applyAgentHostSessionConfig.ts index 64e6be934bf82c..a4b25dd470f4a0 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/applyAgentHostSessionConfig.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/applyAgentHostSessionConfig.ts @@ -7,12 +7,13 @@ import { URI } from '../../../../../../base/common/uri.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; +import { IAgentHostConnectionsService } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { SessionConfigKey } from '../../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { ActionType } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; import { StateComponents } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ChatConfiguration, ChatPermissionLevel } from '../../../common/constants.js'; import { isUntitledChatSession } from '../../../common/model/chatUri.js'; -import { toAgentHostBackendSessionUri } from './agentHostSessionUri.js'; +import { getLocalAgentHostSessionProvider, toAgentHostBackendSessionUri } from './agentHostSessionUri.js'; import { IAgentHostSessionWorkingDirectoryResolver } from './agentHostSessionWorkingDirectoryResolver.js'; import { IAgentHostUntitledProvisionalSessionService } from './agentHostUntitledProvisionalSessionService.js'; @@ -22,6 +23,7 @@ import { IAgentHostUntitledProvisionalSessionService } from './agentHostUntitled */ export interface IApplyAgentHostSessionConfigServices { readonly agentHostService: IAgentHostService; + readonly connectionsService: IAgentHostConnectionsService; readonly provisionalService: IAgentHostUntitledProvisionalSessionService; readonly workingDirectoryResolver: IAgentHostSessionWorkingDirectoryResolver; readonly workspaceContextService: IWorkspaceContextService; @@ -47,12 +49,12 @@ export async function applyAgentHostSessionConfigChange( config: Readonly>, services: IApplyAgentHostSessionConfigServices, ): Promise { - const backendSession = toAgentHostBackendSessionUri(sessionResource); - if (!backendSession) { + const provider = getLocalAgentHostSessionProvider(sessionResource); + if (!provider) { return false; } - const { agentHostService, provisionalService, workingDirectoryResolver, workspaceContextService, configurationService } = services; + const { agentHostService, connectionsService, provisionalService, workingDirectoryResolver, workspaceContextService, configurationService } = services; const policyRestricted = configurationService.inspect(ChatConfiguration.GlobalAutoApprove).policyValue === false; const partial: Record = { ...config }; const autoApprove = partial[SessionConfigKey.AutoApprove]; @@ -64,10 +66,14 @@ export async function applyAgentHostSessionConfigChange( ?? workspaceContextService.getWorkspace().folders[0]?.uri; if (isUntitledChatSession(sessionResource)) { - await provisionalService.applyConfigChange(sessionResource, backendSession.scheme, workingDirectory, partial); + await provisionalService.applyConfigChange(sessionResource, provider, workingDirectory, partial); return true; } + const backendSession = toAgentHostBackendSessionUri(sessionResource, connectionsService); + if (!backendSession) { + return false; + } agentHostService.dispatch(backendSession.toString(), { type: ActionType.SessionConfigChanged, config: partial, @@ -75,6 +81,6 @@ export async function applyAgentHostSessionConfigChange( const state = agentHostService.getSubscriptionUnmanaged(StateComponents.Session, backendSession)?.value; const currentValues = state && !(state instanceof Error) ? state.config?.values : undefined; const nextConfig = { ...(currentValues ?? {}), ...partial }; - void provisionalService.refreshResolvedConfig(sessionResource, backendSession.scheme, workingDirectory, nextConfig); + void provisionalService.refreshResolvedConfig(sessionResource, provider, workingDirectory, nextConfig); return true; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/applyAgentHostSubmitConfig.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/applyAgentHostSubmitConfig.ts index bb14c56617095c..3d68bb4c1bedd8 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/applyAgentHostSubmitConfig.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/applyAgentHostSubmitConfig.ts @@ -5,6 +5,7 @@ import { URI } from '../../../../../../base/common/uri.js'; import { IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; +import { IAgentHostConnectionsService } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; import { IStorageService } from '../../../../../../platform/storage/common/storage.js'; @@ -16,6 +17,7 @@ import { applyAgentHostSessionConfigChange } from './applyAgentHostSessionConfig export interface IApplyAgentHostSubmitConfigServices { readonly agentHostService: IAgentHostService; + readonly connectionsService: IAgentHostConnectionsService; readonly provisionalService: IAgentHostUntitledProvisionalSessionService; readonly workingDirectoryResolver: IAgentHostSessionWorkingDirectoryResolver; readonly workspaceContextService: IWorkspaceContextService; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/codexContinuation.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/codexContinuation.contribution.ts new file mode 100644 index 00000000000000..54a78328dc7acc --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/codexContinuation.contribution.ts @@ -0,0 +1,99 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableStore, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { localize } from '../../../../../../nls.js'; +import { CommandsRegistry } from '../../../../../../platform/commands/common/commands.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { INotificationService, NotificationsFilter, Severity } from '../../../../../../platform/notification/common/notification.js'; +import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../common/contributions.js'; +import { ICodexContinuationService } from '../../../../../services/agentHost/browser/codexContinuationService.js'; +import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; +import { IWorkbenchLayoutService, Parts } from '../../../../../services/layout/browser/layoutService.js'; +import { IViewsService } from '../../../../../services/views/common/viewsService.js'; +import { ChatViewId, IChatWidgetService } from '../../chat.js'; +import { ChatViewPane } from '../../widgetHosts/viewPane/chatViewPane.js'; +import { CODEX_CONTINUATION_DISABLE_LABEL, CODEX_CONTINUATION_LABEL, CODEX_CONTINUATION_MESSAGE, CodexContinuationGuide, ICodexContinuationNavigation } from './codexContinuationGuide.js'; +import { CodexContinuationPresenter } from './codexContinuationPresenter.js'; + +class CodexContinuationContribution extends Disposable { + static readonly ID = 'workbench.contrib.codexContinuation'; + constructor( + @IWorkbenchEnvironmentService environment: IWorkbenchEnvironmentService, + @IInstantiationService instantiation: IInstantiationService, + @INotificationService notifications: INotificationService, + @IChatWidgetService widgets: IChatWidgetService, + @ICodexContinuationService nudge: ICodexContinuationService, + @IViewsService views: IViewsService, + ) { + super(); + if (environment.isSessionsWindow) { return; } + const guide = this._register(instantiation.createInstance(CodexContinuationGuide)); + const navigation: ICodexContinuationNavigation = { + getActiveWidget: () => widgets.lastFocusedWidget, + revealSession: async (resource, token) => { + if (token.isCancellationRequested) { return undefined; } + const view = await views.openView(ChatViewId, true); + if (!view?.agentSessionsControl || token.isCancellationRequested) { return undefined; } + const list = view.agentSessionsControl; + const revealStore = new DisposableStore(); + revealStore.add(view.revealSessionsList()); + try { + const reveal = await list.revealSession(resource, token); + if (!reveal) { revealStore.dispose(); return undefined; } + revealStore.add(reveal); + if (token.isCancellationRequested) { revealStore.dispose(); return undefined; } + return { + onDidOpen: list.onDidOpenSession, + getElement: () => list.getSessionElement(resource), + open: async token => !token.isCancellationRequested && !!await widgets.openSession(resource), + focus: () => list.focus(), + dispose: () => revealStore.dispose(), + }; + } catch (error) { revealStore.dispose(); throw error; } + }, + }; + const active = () => nudge.setActiveSession(widgets.lastFocusedWidget?.viewModel?.sessionResource); + this._register(widgets.onDidChangeFocusedSession(active)); + active(); + this._register(instantiation.createInstance(CodexContinuationPresenter, { + surface: 'editorWindow', + onDidChangePresentability: notifications.onDidChangeFilter, + isPresentable: () => notifications.getFilter() === NotificationsFilter.OFF, + show: (candidate, visible, close, runAction) => { + const store = new DisposableStore(); + let programmaticClose = false; + const handle = notifications.prompt(Severity.Info, CODEX_CONTINUATION_MESSAGE, [ + { label: CODEX_CONTINUATION_LABEL, keepOpen: true, run: () => runAction(() => guide.run(candidate, 'editorWindow', navigation)) }, + { label: CODEX_CONTINUATION_DISABLE_LABEL, keepOpen: true, run: () => runAction(() => nudge.disable('editorWindow')) }, + ]); + store.add(handle.onDidChangeVisibility(shown => { if (shown) { visible(); } })); + store.add(handle.onDidClose(() => { + if (!programmaticClose) { close('dismissed'); } + })); + store.add(toDisposable(() => { + programmaticClose = true; + handle.close(); + })); + if (handle.visible) { visible(); } + return store; + }, + })); + } +} +registerWorkbenchContribution2(CodexContinuationContribution.ID, CodexContinuationContribution, WorkbenchPhase.AfterRestored); + +// Private commands are manually bindable, but are not offered in the Command +// Palette or the Keyboard Shortcuts editor's unbound-command list. +CommandsRegistry.registerCommand('_chat.showCodexContinuation', async accessor => { + const nudge = accessor.get(ICodexContinuationService); + const notifications = accessor.get(INotificationService); + if (accessor.get(IWorkbenchEnvironmentService).isSessionsWindow) { + accessor.get(IWorkbenchLayoutService).setPartHidden(false, Parts.SIDEBAR_PART); + } + if (!await nudge.showPreview()) { + notifications.info(localize('codexContinuation.previewUnavailable', "To preview the suggestion, open a Codex conversation that uses ChatGPT and has a matching model available through GitHub Copilot.")); + } +}); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/codexContinuationGuide.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/codexContinuationGuide.ts new file mode 100644 index 00000000000000..e5ca5660ad3e27 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/codexContinuationGuide.ts @@ -0,0 +1,298 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { disposableTimeout } from '../../../../../../base/common/async.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../../../base/common/observable.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { generateUuid } from '../../../../../../base/common/uuid.js'; +import { localize } from '../../../../../../nls.js'; +import { IAgentHostConnectionsService } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { ActionType } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; +import { StateComponents } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; +import { IWorkspaceTrustManagementService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; +import { ICodexContinuationCandidate, CodexContinuationSurface } from '../../../../../services/agentHost/browser/codexContinuation.js'; +import { ICodexContinuationService } from '../../../../../services/agentHost/browser/codexContinuationService.js'; +import { IHostService } from '../../../../../services/host/browser/host.js'; +import { markOnboardingTarget, registerOnboardingTargetProvider } from '../../../../onboarding/browser/spotlight/onboardingTarget.js'; +import { ISpotlightPayload, ISpotlightStep, SPOTLIGHT_PRESENTATION_KIND } from '../../../../onboarding/browser/spotlight/spotlightTypes.js'; +import { onboardingScenarioRegistry } from '../../../../onboarding/common/onboardingRegistry.js'; +import { OnboardingOutcome } from '../../../../onboarding/common/onboardingScenario.js'; +import { IOnboardingScenarioService } from '../../../../onboarding/common/onboardingScenarioService.js'; +import { IChatWidget, IChatWidgetService } from '../../chat.js'; + +export const CODEX_CONTINUATION_MESSAGE = localize('codexContinuation.message', "You're nearing your ChatGPT limit. Continue your Codex sessions with your Copilot subscription."); +export const CODEX_CONTINUATION_LABEL = localize('codexContinuation.continue', "Continue with Copilot"); +export const CODEX_CONTINUATION_DISABLE_LABEL = localize('codexContinuation.disable', "Don't Show Again"); + +const WIDGET_MATERIALIZATION_TIMEOUT = 5_000; + +export interface ICodexContinuationSessionTarget extends IDisposable { + readonly onDidOpen: Event; + getElement(): HTMLElement | undefined; + open(token: CancellationToken): Promise; + focus(): void; +} + +export interface ICodexContinuationNavigation { + getActiveWidget(): IChatWidget | undefined; + revealSession(resource: URI, token: CancellationToken): Promise; +} + +/** + * Waits for a user-opened session's active widget to materialize. Never opens a + * session or falls back to an inactive widget that happens to show the same chat. + */ +export function waitForActiveChatWidget( + resource: URI, + widgets: IChatWidgetService, + getActiveWidget: () => IChatWidget | undefined, + token: CancellationToken, + timeoutMs = WIDGET_MATERIALIZATION_TIMEOUT, +): Promise { + const store = new DisposableStore(); + let settled = false; + let resolveResult: (widget: IChatWidget | undefined) => void; + const result = new Promise(resolve => { + resolveResult = resolve; + }); + const matches = (widget: IChatWidget | undefined): widget is IChatWidget => + !!widget?.visible && widget.viewModel?.sessionResource.toString() === resource.toString(); + const finish = (widget: IChatWidget | undefined) => { + if (settled) { + return; + } + settled = true; + store.dispose(); + resolveResult(widget); + }; + const tryFinish = () => { + const active = getActiveWidget(); + if (matches(active)) { finish(active); } + }; + const observe = (widget: IChatWidget) => store.add(widget.onDidChangeViewModel(tryFinish)); + for (const widget of widgets.getAllWidgets()) { + observe(widget); + } + store.add(widgets.onDidAddWidget(widget => { + observe(widget); + tryFinish(); + })); + store.add(widgets.onDidChangeFocusedSession(tryFinish)); + store.add(widgets.onDidChangeWidgetVisibility(tryFinish)); + store.add(token.onCancellationRequested(() => finish(undefined))); + store.add(disposableTimeout(() => finish(undefined), timeoutMs)); + + if (token.isCancellationRequested) { finish(undefined); } + else { tryFinish(); } + return result; +} + +/** One exact ChatWidget adapter shared by editor and Agents windows. */ +export class CodexContinuationGuide extends Disposable { + private readonly _run = this._register(new MutableDisposable()); + constructor( + @ICodexContinuationService private readonly _nudge: ICodexContinuationService, + @IAgentHostConnectionsService private readonly _connections: IAgentHostConnectionsService, + @IOnboardingScenarioService private readonly _onboarding: IOnboardingScenarioService, + @IWorkspaceTrustManagementService private readonly _trust: IWorkspaceTrustManagementService, + @INotificationService private readonly _notifications: INotificationService, + @IHostService private readonly _host: IHostService, + @IChatWidgetService private readonly _widgets: IChatWidgetService, + ) { super(); } + + async run(candidate: ICodexContinuationCandidate, surface: CodexContinuationSurface, navigation: ICodexContinuationNavigation): Promise { + const store = new DisposableStore(); + this._run.value = store; + const cancellation = new CancellationTokenSource(); + store.add(toDisposable(() => cancellation.dispose(true))); + const row = store.add(new MutableDisposable()); + let widget: IChatWidget | undefined; + let completed = false; + let guideShown = false; + let unavailable = false; + this._nudge.log('continueClicked', surface); + try { + const current = await this._nudge.resolve(candidate); + if (!current || store.isDisposed || !this._host.hasFocus || !this._trust.isWorkspaceTrusted()) { throw new Error('unavailable'); } + const resource = this._connections.getSessionResource(current.session.session); + if (!resource) { throw new Error('unavailable'); } + const activeWidget = () => { + const active = navigation.getActiveWidget(); + return active?.visible && active.viewModel?.sessionResource.toString() === resource.toString() ? active : undefined; + }; + const validRun = () => !store.isDisposed && !cancellation.token.isCancellationRequested + && this._nudge.ownsEpisode() && this._trust.isWorkspaceTrusted(); + const validOwner = () => validRun() && !!widget && widget === activeWidget(); + const abortUnavailable = () => { unavailable = true; cancellation.cancel(); }; + const revalidate = async () => { + if (!validRun()) { return false; } + const eligible = await this._nudge.resolve(candidate); + if (!validRun()) { return false; } + if (!eligible) { abortUnavailable(); return false; } + return true; + }; + const didShow = () => { + if (!guideShown) { + guideShown = true; + store.add(this._nudge.trackVisibility()); + this._nudge.log('guideShown', surface); + } + }; + store.add(autorun(reader => { + this._nudge.revision.read(reader); + if (!validRun()) { cancellation.cancel(); } + })); + store.add(this._trust.onDidChangeTrust(trusted => { if (!trusted) { cancellation.cancel(); } })); + + widget = activeWidget(); + const id = `codex.continuation.${generateUuid()}`; + const steps: ISpotlightStep[] = []; + if (!widget) { + row.value = await navigation.revealSession(resource, cancellation.token); + const target = row.value; + if (!target || !validRun()) { throw new Error('unavailable'); } + const opened = store.add(new Emitter>()); + const onSessionOpened = (token: CancellationToken) => { + opened.fire((async () => { + const active = await waitForActiveChatWidget(resource, this._widgets, activeWidget, token); + if (token.isCancellationRequested || !validRun()) { return false; } + widget = active; + if (!widget || !await revalidate()) { abortUnavailable(); return false; } + return !token.isCancellationRequested; + })()); + }; + store.add(target.onDidOpen(openedResource => { + if (openedResource.toString() !== resource.toString()) { cancellation.cancel(); return; } + onSessionOpened(cancellation.token); + })); + const rowId = `${id}.session`; + store.add(registerOnboardingTargetProvider(rowId, () => { + const element = validRun() ? target.getElement() : undefined; + return element ? { element, onDidSelect: opened.event } : undefined; + })); + steps.push({ + id: 'openSession', targetId: rowId, + title: localize('codexContinuation.openSession', "Open a Codex Session"), + description: localize('codexContinuation.openSession.description', "This Codex session uses your ChatGPT subscription. Open it to continue with your Copilot subscription."), + placement: 'right', allowTargetInteraction: true, advanceOnTargetSelection: true, + primaryAction: { + label: localize('codexContinuation.openSession.action', "Open Session"), + run: async token => { + if (token.isCancellationRequested || !await revalidate() || token.isCancellationRequested) { return; } + try { + const didOpen = await target.open(token); + if (token.isCancellationRequested || !validRun()) { return; } + if (!didOpen) { cancellation.cancel(); return; } + onSessionOpened(token); + } catch { if (!token.isCancellationRequested && validRun()) { abortUnavailable(); } } + }, + }, + missingTarget: { kind: 'wait', timeoutMs: WIDGET_MATERIALIZATION_TIMEOUT, onTimeout: 'abort' }, + onBeforeShow: async () => { await revalidate(); }, + onDidShow: didShow, + }); + } + + const accepted = store.add(new Emitter>()); + const markedTarget = store.add(new MutableDisposable()); + let markedElement: HTMLElement | undefined; + let prepared = false; + const getTarget = () => widget?.input.availableLanguageModels.find(model => model.metadata.id === current.target.id && model.metadata.isUserSelectable !== false); + store.add(registerOnboardingTargetProvider(id, () => { + if (!prepared || !validOwner()) { return undefined; } + const control = widget!.input.getModelPickerControl(); + const target = getTarget(); + if (!control || !target) { return undefined; } + if (markedElement !== control.element) { + markedElement = control.element; + markedTarget.value = markOnboardingTarget(control.element, id, { onDidSelect: accepted.event }); + } + return { + element: control.element, + open: () => { + const currentControl = widget?.input.getModelPickerControl(); + const currentTarget = getTarget(); + if (validOwner() && currentControl?.element === control.element && currentTarget?.identifier === target.identifier) { + currentControl.open({ initialFilterValue: currentTarget.metadata.name, initialFocusItemId: currentTarget.identifier }); + } + }, + }; + })); + steps.push({ + id: 'chooseCopilot', targetId: id, title: CODEX_CONTINUATION_LABEL, + description: localize('codexContinuation.guide', "Select {0} from GitHub Copilot to continue this session.", current.target.name), + placement: 'left', openTarget: true, allowTargetInteraction: true, advanceOnTargetSelection: true, + primaryAction: { + label: localize('codexContinuation.useCopilot', "Use Copilot"), + run: async token => { + if (token.isCancellationRequested || !await revalidate() || token.isCancellationRequested) { return; } + if (!validOwner() || widget!.viewModel!.model.isInputBlocked.get()) { abortUnavailable(); return; } + const target = getTarget(); + if (target && widget!.input.selectedLanguageModel.get()?.identifier === target.identifier) { cancellation.cancel(); return; } + if (!target || !widget!.input.getModelPickerControl()?.select(target.identifier)) { abortUnavailable(); } + }, + }, + missingTarget: { kind: 'wait', timeoutMs: WIDGET_MATERIALIZATION_TIMEOUT, onTimeout: 'abort' }, + onDidShow: didShow, + onBeforeShow: async () => { + if (!await revalidate()) { return; } + if (!validOwner() || widget!.viewModel!.model.isInputBlocked.get()) { abortUnavailable(); return; } + if (prepared) { return; } + const owner = widget!; + const resolution = this._connections.resolveSessionResource(resource); + const session = resolution?.connection.getSubscriptionUnmanaged(StateComponents.Session, resolution.backendSession)?.verifiedValue; + const chat = session?.defaultChat && resolution?.connection.getSubscriptionUnmanaged(StateComponents.Chat, URI.parse(session.defaultChat)); + if (!resolution || !chat) { abortUnavailable(); return; } + let explicitSelection = false; + store.add(owner.input.onDidChangeUserSelectedModel(event => { + explicitSelection = event.toModelId === getTarget()?.identifier; + if (!explicitSelection) { cancellation.cancel(); } + })); + store.add(owner.onDidChangeViewModel(() => { + if (owner.viewModel?.sessionResource.toString() !== resource.toString()) { cancellation.cancel(); } + })); + store.add(this._widgets.onDidChangeFocusedSession(() => { if (!validOwner()) { cancellation.cancel(); } })); + store.add(chat.onDidApplyAction(envelope => { + if (envelope.action.type !== ActionType.ChatDraftChanged || envelope.origin?.clientId !== resolution.connection.clientId) { return; } + if (envelope.rejectionReason) { cancellation.cancel(); return; } + const target = getTarget(); + if (target && explicitSelection && validOwner() && chat.verifiedValue?.draft?.model?.id === target.metadata.id + && owner.input.selectedLanguageModel.get()?.identifier === target.identifier) { + accepted.fire((async () => { + const eligible = await this._nudge.resolve(candidate, undefined, true); + completed = !!eligible && validOwner() && chat.verifiedValue?.draft?.model?.id === target.metadata.id; + return completed; + })()); + } + })); + prepared = true; + }, + }); + store.add(onboardingScenarioRegistry.register({ + id, repeatable: true, trigger: { kind: 'command', commandId: id }, + presentation: { kind: SPOTLIGHT_PRESENTATION_KIND, payload: { steps } satisfies ISpotlightPayload }, + })); + const outcome = await this._onboarding.runScenario(id, cancellation.token); + if (unavailable) { throw new Error('unavailable'); } + if (completed && outcome === OnboardingOutcome.Completed && validOwner()) { await this._nudge.complete(surface); } + else { this._nudge.log('guideCancelled', surface); } + } catch { + if (!store.isDisposed) { + this._nudge.log('guideUnavailable', surface); + this._notifications.info(localize('codexContinuation.unavailable', "This Copilot continuation is no longer available. Review the session's model picker to continue.")); + } + } finally { + if (!store.isDisposed && this._host.hasFocus) { + if (widget?.visible && widget === navigation.getActiveWidget()) { widget.focusInput(); } + else { row.value?.focus(); } + } + store.dispose(); + } + } +} diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/codexContinuationPresenter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/codexContinuationPresenter.ts new file mode 100644 index 00000000000000..67948fd946f664 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/codexContinuationPresenter.ts @@ -0,0 +1,127 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { RunOnceScheduler } from '../../../../../../base/common/async.js'; +import { onUnexpectedError } from '../../../../../../base/common/errors.js'; +import { Event } from '../../../../../../base/common/event.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../../../base/common/observable.js'; +import { CodexContinuationSurface, ICodexContinuationCandidate } from '../../../../../services/agentHost/browser/codexContinuation.js'; +import { ICodexContinuationService } from '../../../../../services/agentHost/browser/codexContinuationService.js'; +import { ILanguageModelsService } from '../../../common/languageModels.js'; +import { IHostService } from '../../../../../services/host/browser/host.js'; + +export interface ICodexContinuationPresentation { + readonly surface: CodexContinuationSurface; + readonly onDidChangePresentability: Event; + isPresentable(): boolean; + show(candidate: ICodexContinuationCandidate, visible: () => Promise, close: (reason: 'action' | 'dismissed') => void, runAction: (action: () => Promise | void) => Promise): IDisposable; +} + +/** Keeps both surfaces on the same experiment and visibility boundary. */ +export class CodexContinuationPresenter extends Disposable { + private readonly _presentation = this._register(new MutableDisposable()); + private readonly _schedule = this._register(new RunOnceScheduler(() => { void this._update(); }, 0)); + private _generation = 0; + private _visible = false; + private _opening = false; + + constructor( + private readonly _delegate: ICodexContinuationPresentation, + @ICodexContinuationService private readonly _nudge: ICodexContinuationService, + @IHostService private readonly _host: IHostService, + @ILanguageModelsService models: ILanguageModelsService, + ) { + super(); + const updateModels = () => _nudge.setSelectableModels(models.getLanguageModelIds().flatMap(id => { + const model = models.lookupLanguageModel(id); + return model && model.isUserSelectable !== false ? [{ id: model.id, vendor: model.vendor }] : []; + })); + this._register(models.onDidChangeLanguageModels(updateModels)); + updateModels(); + const schedule = () => { this._generation++; this._schedule.schedule(); }; + this._register(autorun(reader => { _nudge.candidate.read(reader); _nudge.revision.read(reader); schedule(); })); + this._register(_host.onDidChangeFocus(schedule)); + this._register(_delegate.onDidChangePresentability(schedule)); + this._register(toDisposable(() => { void _nudge.releasePresentation(); _nudge.endPreview(); })); + } + + private async _update(): Promise { + if (this._store.isDisposed) { return; } + const surfacePresentable = this._delegate.isPresentable() && !!this._nudge.candidate.get(); + if (this._presentation.value) { + // Focus gates the initial presentation and claim, but ordinary window + // switching must not consume a nudge the user can no longer see. + if (!surfacePresentable || (!this._visible && !this._host.hasFocus) || (this._visible && !this._nudge.ownsEpisode())) { + this._presentation.clear(); + this._visible = false; + void this._nudge.releasePresentation(); + this._nudge.endPreview(); + } + return; + } + if (!surfacePresentable || !this._host.hasFocus || this._opening) { return; } + const generation = this._generation; + this._opening = true; + try { + if (!await this._nudge.wouldShow(this._delegate.surface)) { return; } + const candidate = await this._nudge.resolve(); + if (!candidate || this._store.isDisposed || generation !== this._generation || !this._host.hasFocus || !this._delegate.isPresentable()) { return; } + if (!await this._nudge.reservePresentation()) { return; } + if (this._store.isDisposed || !this._host.hasFocus || !this._delegate.isPresentable()) { await this._nudge.releasePresentation(); return; } + this._visible = false; + const presentation = new DisposableStore(); + this._presentation.value = presentation; + let visibilityClaim: Promise | undefined; + let actionPending = false; + const isVisible = () => !presentation.isDisposed && !this._store.isDisposed && this._host.hasFocus && this._delegate.isPresentable(); + const close = (reason: 'action' | 'dismissed' | 'unavailable') => { + // A late callback belongs only to the presentation that created it. + if (this._presentation.value !== presentation) { return; } + if (this._visible && reason === 'dismissed') { this._nudge.dismiss(this._delegate.surface); } + this._presentation.clear(); + this._visible = false; + void this._nudge.releasePresentation(); + if (reason !== 'action') { this._nudge.endPreview(); } + }; + const claimVisibility = (): Promise => { + if (!isVisible()) { return Promise.resolve(false); } + return visibilityClaim ??= (async () => { + try { + const visible = await this._nudge.markVisible(this._delegate.surface, candidate, isVisible); + if (presentation.isDisposed) { return false; } + this._visible = visible; + if (visible) { presentation.add(this._nudge.trackVisibility()); } + else { close('unavailable'); } + return visible; + } catch (error) { + close('unavailable'); + onUnexpectedError(error); + return false; + } + })(); + }; + presentation.add(this._delegate.show(candidate, claimVisibility, close, async action => { + if (actionPending || !isVisible()) { return; } + actionPending = true; + // Clicking is itself evidence of visibility. Share the in-flight claim + // with the render callback, and keep the surface alive until it settles. + if (!await claimVisibility() || !isVisible()) { return; } + close('action'); + try { await action(); } catch (error) { onUnexpectedError(error); } + finally { this._nudge.endPreview(); } + })); + + } catch { + this._presentation.clear(); + this._visible = false; + await this._nudge.releasePresentation(); + this._nudge.endPreview(); + } finally { + this._opening = false; + if (generation !== this._generation && !this._store.isDisposed) { this._schedule.schedule(); } + } + } +} diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/copilotConfigSlashSubmitHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/copilotConfigSlashSubmitHandler.ts index 903af92687e6a9..f39d3e42096df0 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/copilotConfigSlashSubmitHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/copilotConfigSlashSubmitHandler.ts @@ -18,6 +18,7 @@ import { getChatSessionType } from '../../../common/model/chatUri.js'; import { IAgentHostSessionWorkingDirectoryResolver } from './agentHostSessionWorkingDirectoryResolver.js'; import { IAgentHostUntitledProvisionalSessionService } from './agentHostUntitledProvisionalSessionService.js'; import { applyAgentHostSubmitConfig } from './applyAgentHostSubmitConfig.js'; +import { IAgentHostConnectionsService } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; export interface ICopilotConfigSlashSubmitResolution { readonly applyConfig: Readonly>; @@ -37,6 +38,7 @@ export class CopilotConfigSlashSubmitHandlerContribution extends Disposable impl constructor( @IChatSubmitRequestHandlerService submitRequestHandlerService: IChatSubmitRequestHandlerService, @IAgentHostService private readonly _agentHostService: IAgentHostService, + @IAgentHostConnectionsService private readonly _connectionsService: IAgentHostConnectionsService, @IAgentHostUntitledProvisionalSessionService private readonly _provisionalService: IAgentHostUntitledProvisionalSessionService, @IAgentHostSessionWorkingDirectoryResolver private readonly _workingDirectoryResolver: IAgentHostSessionWorkingDirectoryResolver, @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, @@ -61,6 +63,7 @@ export class CopilotConfigSlashSubmitHandlerContribution extends Disposable impl } await applyAgentHostSubmitConfig(request.sessionResource, configAction.applyConfig, { agentHostService: this._agentHostService, + connectionsService: this._connectionsService, provisionalService: this._provisionalService, workingDirectoryResolver: this._workingDirectoryResolver, workspaceContextService: this._workspaceContextService, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/openSessionLinkOpener.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/openSessionLinkOpener.contribution.ts index a44807b0a6fcc8..66d61a57b5aa5d 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/openSessionLinkOpener.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/openSessionLinkOpener.contribution.ts @@ -13,8 +13,8 @@ import { IObservable, observableValueOpts } from '../../../../../../base/common/ import { isEqual } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { AgentSession } from '../../../../../../platform/agentHost/common/agentService.js'; -import { LOCAL_AGENT_HOST_SCHEME_PREFIX } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; -import { AGENT_HOST_CHAT_LINK_PATTERN, AGENT_HOST_SESSION_LINK_SCHEME, AGENT_HOST_SESSION_ONLY_LINK_PATTERN, AgentSessionLinkStatus, buildAgentSessionLinkPresentation, parseOpenSessionLinkChatId, parseOpenSessionLinkUri } from '../../../../../../platform/agentHost/common/openSessionLink.js'; +import { AMBIENT_AGENT_HOST_AUTHORITY, IAgentHostConnectionsService, LOCAL_AGENT_HOST_SCHEME_PREFIX } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { AGENT_HOST_CHAT_LINK_PATTERN, AGENT_HOST_SESSION_LINK_SCHEME, AGENT_HOST_SESSION_ONLY_LINK_PATTERN, AgentSessionLinkStatus, buildAgentSessionLinkPresentation, parseOpenSessionLinkChatId, parseOpenSessionLinkConnectionAuthority, parseOpenSessionLinkUri } from '../../../../../../platform/agentHost/common/openSessionLink.js'; import { ILinkPresentation, ILinkPresentationService, ILinkPresentationWatcher } from '../../../../../../platform/dataChannel/common/dataChannel.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; @@ -48,6 +48,7 @@ import { ISessionSummaryHoverService } from '../sessionSummaryHoverService.js'; export class AgentHostOpenSessionLinkOpenerContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.chat.agentHostOpenSessionLinkOpener'; + private readonly _sessionListRefreshes = new Map>(); constructor( @IOpenerService openerService: IOpenerService, @@ -58,6 +59,7 @@ export class AgentHostOpenSessionLinkOpenerContribution extends Disposable imple @ILogService logService: ILogService, @ISessionSummaryHoverService sessionSummaryHoverService: ISessionSummaryHoverService, @IPathService pathService: IPathService, + @IAgentHostConnectionsService private readonly _connectionsService: IAgentHostConnectionsService, ) { super(); this._register(openerService.registerOpener({ @@ -74,11 +76,10 @@ export class AgentHostOpenSessionLinkOpenerContribution extends Disposable imple kind: 'session', }, { createLinkPresentationWatcher: resource => { - const clientResource = toClientSessionResource(resource); - if (!clientResource) { + if (!parseOpenSessionLinkUri(resource)) { throw new Error(`Invalid agent session link: ${resource.toString(true)}`); } - return new WorkbenchAgentSessionLinkPresentationWatcher(clientResource, 'session', this._chatSessionsService, logService); + return new WorkbenchAgentSessionLinkPresentationWatcher(token => this._resolveClientSessionResource(resource, token), 'session', this._chatSessionsService, logService, this._connectionsService.onDidChangeSessionResolution); }, })); this._register(linkPresentationService.registerLinkPresentationProvider({ @@ -87,11 +88,10 @@ export class AgentHostOpenSessionLinkOpenerContribution extends Disposable imple kind: 'chat', }, { createLinkPresentationWatcher: resource => { - const clientResource = toClientSessionResource(resource); - if (!clientResource) { + if (!parseOpenSessionLinkUri(resource)) { throw new Error(`Invalid agent chat link: ${resource.toString(true)}`); } - return new WorkbenchAgentSessionLinkPresentationWatcher(clientResource, 'chat', this._chatSessionsService, logService); + return new WorkbenchAgentSessionLinkPresentationWatcher(token => this._resolveClientSessionResource(resource, token), 'chat', this._chatSessionsService, logService, this._connectionsService.onDidChangeSessionResolution); }, })); // The editor window's adapter onto the shared session hover. It resolves @@ -108,7 +108,7 @@ export class AgentHostOpenSessionLinkOpenerContribution extends Disposable imple } private async _findChatSessionItem(resource: URI, token: CancellationToken): Promise { - const clientResource = toClientSessionResource(resource); + const clientResource = await this._resolveClientSessionResource(resource, token); if (!clientResource) { return undefined; } @@ -118,7 +118,7 @@ export class AgentHostOpenSessionLinkOpenerContribution extends Disposable imple } private async _open(resource: URI | string): Promise { - const clientResource = toClientSessionResource(resource); + const clientResource = await this._resolveClientSessionResource(resource, CancellationToken.None); if (!clientResource) { return false; } @@ -126,6 +126,37 @@ export class AgentHostOpenSessionLinkOpenerContribution extends Disposable imple const widget = await this._chatWidgetService.openSession(clientResource, ChatViewPaneTarget, { revealIfOpened: true }); return !!widget; } + + private async _resolveClientSessionResource(resource: URI | string, token: CancellationToken): Promise { + const cached = toClientSessionResource(resource, this._connectionsService); + if (cached || !parseOpenSessionLinkUri(resource)) { + return cached; + } + const authority = parseOpenSessionLinkConnectionAuthority(resource) ?? AMBIENT_AGENT_HOST_AUTHORITY; + let refresh = this._sessionListRefreshes.get(authority); + if (!refresh) { + refresh = this._loadSessionIdentities(authority).finally(() => { + if (this._sessionListRefreshes.get(authority) === refresh) { + this._sessionListRefreshes.delete(authority); + } + }); + this._sessionListRefreshes.set(authority, refresh); + } + await refresh; + return token.isCancellationRequested ? undefined : toClientSessionResource(resource, this._connectionsService); + } + + private async _loadSessionIdentities(authority: string): Promise { + const connection = authority === AMBIENT_AGENT_HOST_AUTHORITY + ? this._connectionsService.ambientConnection + : this._connectionsService.getConnectionByAuthority(authority); + if (!connection) { + throw new Error(`Agent host is not connected: ${authority}`); + } + for (const metadata of await connection.listSessions()) { + this._connectionsService.registerSessionResource(metadata.session, authority, metadata.provider); + } + } } class WorkbenchAgentSessionLinkPresentationWatcher extends Disposable implements ILinkPresentationWatcher { @@ -135,26 +166,23 @@ class WorkbenchAgentSessionLinkPresentationWatcher extends Disposable implements ); readonly presentation: IObservable = this._data; - private readonly _clientResource: URI; - private readonly _chatSessionType: string; - private readonly _providerReady: Promise; + private _providerReady: Promise | undefined; private _refreshCancellation: CancellationTokenSource | undefined; constructor( - clientResource: URI, + private readonly _resolveClientResource: (token: CancellationToken) => Promise, private readonly _kind: 'session' | 'chat', private readonly _chatSessionsService: IChatSessionsService, private readonly _logService: ILogService, + onDidChangeSessionResolution: Event = Event.None, ) { super(); - this._clientResource = clientResource; - this._chatSessionType = getChatSessionType(this._clientResource); - this._providerReady = this._chatSessionsService.activateChatSessionItemProvider(this._chatSessionType); this._register(Event.any( this._chatSessionsService.onDidChangeAvailability, this._chatSessionsService.onDidChangeInProgress, this._chatSessionsService.onDidChangeItemsProviders, this._chatSessionsService.onDidChangeSessionItems, + onDidChangeSessionResolution, )(() => this._refresh())); this._refresh(); } @@ -183,8 +211,14 @@ class WorkbenchAgentSessionLinkPresentationWatcher extends Disposable implements } private async _resolve(token: CancellationToken): Promise { + const clientResource = await this._resolveClientResource(token); + if (!clientResource || token.isCancellationRequested) { + return undefined; + } + const chatSessionType = getChatSessionType(clientResource); + this._providerReady ??= this._chatSessionsService.activateChatSessionItemProvider(chatSessionType); await this._providerReady; - const match = await findChatSessionItem(this._chatSessionsService, this._chatSessionType, this._clientResource, token); + const match = await findChatSessionItem(this._chatSessionsService, chatSessionType, clientResource, token); return match ? toSessionLinkPresentation(match.item, match.status, this._kind) : undefined; } } @@ -227,11 +261,18 @@ function findChatSessionItemByResource(items: readonly IChatSessionItem[], resou return undefined; } -function toClientSessionResource(resource: URI | string): URI | undefined { +function toClientSessionResource(resource: URI | string, connectionsService: IAgentHostConnectionsService): URI | undefined { const backendSession = parseOpenSessionLinkUri(resource); if (!backendSession) { return undefined; } + const authority = parseOpenSessionLinkConnectionAuthority(resource); + if (authority) { + return connectionsService.findSessionResource(backendSession, authority)?.with({ fragment: parseOpenSessionLinkChatId(resource) ?? '' }); + } + if (backendSession.scheme === 'ahp-session' || backendSession.authority || backendSession.query) { + return connectionsService.findSessionResource(backendSession)?.with({ fragment: parseOpenSessionLinkChatId(resource) ?? '' }); + } const provider = AgentSession.provider(backendSession); const rawId = AgentSession.id(backendSession); return provider && rawId 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 a74d0f611c43e0..2a115be2dcdbfe 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -17,10 +17,11 @@ import { Schemas } from '../../../../../../base/common/network.js'; import { posix, win32 } from '../../../../../../base/common/path.js'; import { URI } from '../../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; -import { buildSubagentChatUri, getTurnError, MessageKind, parseChatUri, ToolCallCancellationReason, ToolCallContributorKind, ToolCallRiskAssessmentStatus, ToolCallStatus, ResponsePartKind, getInlineToolInput, getToolFileEdits, getToolOutputText, getToolSubagentContent, hasReportedUsage, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, type ActiveTurn, type ChatInputAnswer, type ChatInputRequest, type ICompletedToolCall, type InputRequestResponsePart, type Message, type TerminalCommandResult, type ToolCallPendingConfirmationState, type ToolCallState, type ToolResultSubagentContent, type Turn, FileEditKind, ToolResultContentType, type ToolResultContent, type UsageInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { buildSubagentChatUri, getTurnError, MessageKind, ToolCallCancellationReason, ToolCallContributorKind, ToolCallRiskAssessmentStatus, ToolCallStatus, ResponsePartKind, getInlineToolInput, getToolFileEdits, getToolOutputText, getToolSubagentContent, hasReportedUsage, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, type ActiveTurn, type ChatInputAnswer, type ChatInputQuestion, type ChatInputRequest, type ICompletedToolCall, type InputRequestResponsePart, type Message, type TerminalCommandResult, type ToolCallPendingConfirmationState, type ToolCallState, type ToolResultSubagentContent, type Turn, FileEditKind, ToolResultContentType, type ToolResultContent, type UsageInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import type { ChatInputRequestWithPlanReview, IAgentHostPlanReview } from '../../../../../../platform/agentHost/common/agentHostPlanReview.js'; import { getToolKind as getProtocolToolKind } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; import { readToolCallMeta, readToolCallPresentation, type IAgentToolOutputChunk } from '../../../../../../platform/agentHost/common/meta/agentToolCallMeta.js'; +import { readImageGenerationToolMetadata } from '../../../../../../platform/agentHost/common/meta/agentImageGenerationMeta.js'; import { readAttachmentDetail } from '../../../../../../platform/agentHost/common/meta/attachmentMeta.js'; import { COPILOT_HYDRA_FUSION_MODEL_ID } from '../../../../../../platform/agentHost/common/copilotCliConfig.js'; import { getChatErrorDetailsFromMeta, IChatErrorContext } from '../../../common/chatErrorMessages.js'; @@ -36,6 +37,7 @@ import { isAgentMergeMessage } from '../../../../../../platform/agentHost/common import { AgentSystemNotificationKind, AgentSystemNotificationSeverity, AgentSystemNotificationWorkspaceKind, readAgentSystemNotificationMeta } from '../../../../../../platform/agentHost/common/meta/agentSystemNotificationMeta.js'; import { isViewUnreviewedCommentsTool, isAddCommentTool } from '../../../../../../platform/agentHost/common/meta/agentFeedbackAnnotations.js'; import { AGENT_HOST_SESSION_LINK_SCHEME, buildOpenSessionLinkUri, isCreateChatTool, isCreateSessionTool, isSendMessageTool, parseOpenSessionLinkChatId, parseOpenSessionLinkUri } from '../../../../../../platform/agentHost/common/openSessionLink.js'; +import { getAgentHostChatId } from '../../../../../../platform/agentHost/common/agentHostChatIdentity.js'; import { parsePartialToolInputForDisplay } from '../../../../../../platform/agentHost/common/partialToolInput.js'; import { MessageAttachmentKind, type FileEdit, type MessageAttachment, type ResponsePart, type StringOrMarkdown, type TextRange } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { normalizeFileEdit } from '../../../../../../platform/agentHost/common/fileEditDiff.js'; @@ -68,7 +70,7 @@ function getToolKind(call: ToolCallState) { } const agentHostAskUserToolNames = new Set(['ask_user', 'AskUserQuestion', 'request_user_input']); -const imageGenerationToolName = 'image_gen.imagegen'; +const imageGenerationToolNames = new Set(['image_gen.imagegen', 'image_generation']); function isAgentHostAskUserTool(toolName: string): boolean { return agentHostAskUserToolNames.has(toolName); @@ -221,16 +223,35 @@ export function convertProtocolPlanReviewResult(planReview: IAgentHostPlanReview }; } +function getInputQuestionText(question: ChatInputQuestion): { readonly title: string; readonly message: string } { + if (question.title) { + return { title: question.title, message: question.message }; + } + const endOfLine = question.message.indexOf('\n'); + return endOfLine === -1 + ? { title: question.message, message: '' } + : { title: question.message.substring(0, endOfLine).trim(), message: question.message.substring(endOfLine + 1).trim() }; +} + +/** Wraps Agent Host link targets while preserving labels used for plain-text and accessible question text. */ +function rewriteQuestionMarkdownLinks(markdown: string, connectionAuthority: string): string { + return rewriteMarkdownSource(markdown, { + rewriteLink: token => { + const href = rewriteAgentHostLinkTarget(token.href, connectionAuthority); + if (href === token.href) { + return undefined; + } + const prefix = token.type === 'image' ? '![' : '['; + return `${prefix}${escapeMarkdownLinkLabel(token.text ?? '')}](${href})`; + }, + }); +} + export function createInputRequestCarousel(inputReq: ChatInputRequest, connectionAuthority: string): ChatQuestionCarouselData { const questions: IChatQuestion[] = (inputReq.questions ?? []).map((question): IChatQuestion => { - let title = question.title; - let message = question.message; - if (!title) { - const endOfLine = question.message.indexOf('\n'); - title = endOfLine === -1 ? question.message : question.message.substring(0, endOfLine).trim(); - message = endOfLine === -1 ? '' : question.message.substring(endOfLine + 1).trim(); - } - const detailedMessage = new MarkdownString(message, { isTrusted: false }); + const questionText = getInputQuestionText(question); + const title = rewriteQuestionMarkdownLinks(questionText.title, connectionAuthority); + const detailedMessage = new MarkdownString(rewriteQuestionMarkdownLinks(questionText.message, connectionAuthority), { isTrusted: false }); switch (question.kind) { case ChatInputQuestionKind.SingleSelect: @@ -291,18 +312,26 @@ export function createInputRequestCarousel(inputReq: ChatInputRequest, connectio questions.push({ id: 'answer', type: 'text', - title: inputReq.message ?? '', + title: rewriteQuestionMarkdownLinks(inputReq.message ?? '', connectionAuthority), required: true, }); } + const message = inputReq.message; + const firstQuestion = inputReq.questions?.[0]; + const carouselMessage = message + && firstQuestion + && message.trim() !== getInputQuestionText(firstQuestion).title.trim() + && message.trim() !== firstQuestion.message.trim() + ? rawMarkdownToString(message, connectionAuthority) + : undefined; const carousel = new ChatQuestionCarouselData( questions, true, inputReq.id, undefined, undefined, - inputReq.message ? rawMarkdownToString(inputReq.message, connectionAuthority) : undefined, + carouselMessage, ); carousel.answerPresentation = 'conversation'; return carousel; @@ -398,9 +427,15 @@ 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. */ +/** Surfaces host progress and image-model attribution as the invocation's progress step. */ function applyToolCallProgress(invocation: ChatToolInvocation, tc: ToolCallState): void { - const progressMessage = tc.status === ToolCallStatus.Running ? readToolCallMeta(tc).progressMessage : undefined; + if (tc.status !== ToolCallStatus.Running) { + return; + } + const imageModel = readImageGenerationToolMetadata(tc)?.requestedModel; + const progressMessage = imageModel + ? localize('imageGenerationModelProgress', "Generating image with {0}", imageModel.name ?? imageModel.id) + : readToolCallMeta(tc).progressMessage; if (progressMessage !== undefined) { invocation.acceptProgress({ message: progressMessage }); } @@ -1083,7 +1118,7 @@ export function usageInfoToQuotas(usage: UsageInfo | undefined): IAgentHostQuota * Requests preserve the selected model. Response details use the actual model, except for Fusion's workflow label. * The `lookup` callback supplies the session-level fallback for missing model metadata. */ -export function turnsToHistory(backendSession: URI, turns: readonly Turn[], participantId: string, connectionAuthority: string, lookup?: TurnModelLookup, errorContext?: IChatErrorContext, terminalCommandPrefix?: string, resourceUris: IAgentHostResourceUriMapper = createAgentHostResourceUriMapper(connectionAuthority), logicalSessionScheme: string = backendSession.scheme, errorDetailsProvider?: (turn: Turn) => IChatResponseErrorDetails | undefined, contextUsage?: IAgentContextUsage): IChatSessionHistoryItem[] { +export function turnsToHistory(backendSession: URI, turns: readonly Turn[], participantId: string, connectionAuthority: string, lookup?: TurnModelLookup, errorContext?: IChatErrorContext, terminalCommandPrefix?: string, resourceUris: IAgentHostResourceUriMapper = createAgentHostResourceUriMapper(connectionAuthority), errorDetailsProvider?: (turn: Turn) => IChatResponseErrorDetails | undefined, contextUsage?: IAgentContextUsage): IChatSessionHistoryItem[] { const history: IChatSessionHistoryItem[] = []; for (const turn of turns) { const rawModelId = turn.usage?.model; @@ -1092,7 +1127,7 @@ export function turnsToHistory(backendSession: URI, turns: readonly Turn[], part // Request const variableData = messageToVariableData(turn.message, connectionAuthority); - const origin = messageToRequestOrigin(backendSession, turn.message, participantId, logicalSessionScheme); + const origin = messageToRequestOrigin(backendSession, turn.message, participantId, connectionAuthority); const isSystemInitiated = turn.message.origin.kind === MessageKind.SystemNotification; const requestSource = messageToRequestSource(turn.message); // A message runs as a terminal command when it starts with the host's @@ -1212,22 +1247,20 @@ export function messageToRequestSource(message: Message): ChatRequestSource | un return undefined; } -export function messageToRequestOrigin(backendSession: URI, message: Message, participantId: string, logicalSessionScheme: string = backendSession.scheme): IChatRequestOrigin | undefined { +export function messageToRequestOrigin(backendSession: URI, message: Message, participantId: string, connectionAuthority?: string): IChatRequestOrigin | undefined { const delegation = readAgentMessageDelegationMeta(message); if (!delegation) { return undefined; } if (hasKey(delegation, { sourceSession: true })) { const sourceSession = URI.parse(delegation.sourceSession); - const logicalSourceSession = sourceSession.scheme === backendSession.scheme - ? sourceSession.with({ scheme: logicalSessionScheme }) - : sourceSession; return { kind: ChatRequestOriginKind.Delegation, sourceSessionResource: URI.parse(buildOpenSessionLinkUri( - logicalSourceSession, - delegation.sourceChat ? parseChatUri(delegation.sourceChat)?.chatId : undefined, + sourceSession, + delegation.sourceChat ? getAgentHostChatId(delegation.sourceChat) : undefined, delegation.sourceTurnId, + connectionAuthority, )), delegationScope: isEqual(sourceSession, backendSession) ? 'chat' : 'session', }; @@ -1825,10 +1858,7 @@ function buildTerminalToolSpecificData( function getToolInputOutputDetails(tc: ToolCallState, isError: boolean, errorString: string | undefined, includeMcpOutput: boolean, connectionAuthority: string): IToolResultInputOutputDetails | undefined { const toolInput = tc.status === ToolCallStatus.Streaming ? undefined : getInlineToolInput(tc.toolInput); - if (!toolInput) { - return undefined; - } - + const isImageGeneration = isImageGenerationTool(tc); const output: IToolResultInputOutputDetails['output'] = []; if (tc.status === ToolCallStatus.Completed || tc.status === ToolCallStatus.Running) { for (const block of tc.content ?? []) { @@ -1851,12 +1881,16 @@ function getToolInputOutputDetails(tc: ToolCallState, isError: boolean, errorStr } } - if (output.length === 0 && errorString) { + if (output.length === 0 && errorString && (toolInput || isImageGeneration)) { output.push({ type: 'embed', value: errorString, isText: true, mimeType: 'text/plain' }); } + if (!toolInput && (!isImageGeneration || output.length === 0)) { + return undefined; + } + return { - input: toolInput, + input: toolInput ?? '', inputLanguage: 'json', output, isError, @@ -1929,14 +1963,9 @@ function toMcpContentBlock(block: ToolResultContent, connectionAuthority: string } } -/** - * Wraps a tool-result resource URI (string) via {@link toAgentHostUri} so it - * resolves through the agent host filesystem provider on the client. The - * underlying helper has a fast-path that returns the URI unchanged when it's - * already a local `file://` resource, so the wrap is safe for all cases. - */ +/** Routes tool-result content references through `resourceRead`, without requiring filesystem resolution. */ function wrapResourceUri(uri: string, connectionAuthority: string): URI { - return toAgentHostUri(URI.parse(uri), connectionAuthority); + return toAgentHostContentUri(URI.parse(uri), connectionAuthority); } function getToolErrorString(tc: ToolCallState): string | undefined { @@ -1976,16 +2005,34 @@ function buildSessionCreatedToolData(tc: ToolCallState): IChatSessionCreatedData return { kind: 'sessionCreated', openLink, label, fullTitle, ...(isChat ? { isChat: true } : {}) }; } +function isImageGenerationTool(tc: ToolCallState): boolean { + return imageGenerationToolNames.has(tc.toolName) || readImageGenerationToolMetadata(tc) !== undefined; +} + function buildGeneratedImageToolData(tc: ToolCallState): IChatGeneratedImageData | undefined { - if (tc.status !== ToolCallStatus.Completed || !tc.success || tc.toolName !== imageGenerationToolName) { + if (tc.status !== ToolCallStatus.Completed || !tc.success || !isImageGenerationTool(tc)) { return undefined; } - const hasImage = tc.content?.some(block => block.type === ToolResultContentType.EmbeddedResource - && block.contentType.startsWith('image/') - && block.data.length > 0); + const hasImage = tc.content?.some(block => + block.type === ToolResultContentType.EmbeddedResource + ? block.contentType.startsWith('image/') && block.data.length > 0 + : block.type === ToolResultContentType.Resource && block.contentType?.startsWith('image/') && block.uri.length > 0); return hasImage ? { kind: 'generatedImage' } : undefined; } +function buildImageGenerationInputData(tc: ToolCallState): IChatToolInputInvocationData | undefined { + const imageGeneration = readImageGenerationToolMetadata(tc); + if (!isImageGenerationTool(tc)) { + return undefined; + } + return { + kind: 'input', + rawInput: tc.status === ToolCallStatus.Streaming ? tc.partialInput : getInlineToolInput(tc.toolInput), + editable: false, + imageGeneration: imageGeneration ?? {}, + }; +} + function buildAutomationConfiguredToolData(tc: ToolCallState): IChatAutomationConfiguredData | undefined { if (tc.status !== ToolCallStatus.Completed || !tc.success || tc.toolName !== ConfigureAutomationToolReferenceName) { return undefined; @@ -2049,6 +2096,23 @@ function completedToolCallConfirmedReason(tc: ICompletedToolCall): NonNullable | undefined; private sessionsDataSource: AgentSessionsDataSource | undefined; + private sessionRenderer: AgentSessionRenderer | undefined; + private readonly sessionReveal = this._register(new MutableDisposable()); + private readonly _onDidOpenSession = this._register(new Emitter()); + readonly onDidOpenSession = this._onDidOpenSession.event; private static readonly RECENT_SESSIONS_FOR_EXPAND = 5; private sessionsListFindIsOpen = false; @@ -311,7 +317,7 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo const sorter = new AgentSessionsSorter(() => this.options.filter.sortResults?.() ?? AgentSessionsSorting.Created); const approvalModel = this.options.enableApprovalRow ? this._register(this.instantiationService.createInstance(AgentSessionApprovalModel)) : undefined; const activeSessionResource = observableValue(this, undefined); - const sessionRenderer = this._register(this.instantiationService.createInstance(AgentSessionRenderer, { + const sessionRenderer = this.sessionRenderer = this._register(this.instantiationService.createInstance(AgentSessionRenderer, { ...this.options, isGroupedByRepository: () => this.options.filter.groupResults?.() === AgentSessionsGrouping.Repository, isSortedByUpdated: () => this.options.filter.sortResults?.() === AgentSessionsSorting.Updated, @@ -727,10 +733,12 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo const options = this.options.overrideSessionOpenOptions?.(e) ?? e; if (this.options.overrideSessionOpen) { await this.options.overrideSessionOpen(element.resource, options); + this._onDidOpenSession.fire(element.resource); } else { const widget = await this.instantiationService.invokeFunction(openSession, element, options); if (widget) { this.options.notifySessionOpened?.(element.resource, widget); + this._onDidOpenSession.fire(element.resource); } } } @@ -950,6 +958,51 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo return focused.filter(e => isAgentSession(e)); } + /** Temporarily reveals a row through filters and collapsed sections, without opening its chat. */ + async revealSession(resource: URI, token: CancellationToken): Promise { + const list = this.sessionsList; + const dataSource = this.sessionsDataSource; + if (!list || !dataSource || token.isCancellationRequested) { + return undefined; + } + const release = toDisposable(() => { + dataSource.setRevealedSession(undefined); + if (!this._store.isDisposed) { void this.update().catch(onUnexpectedError); } + }); + this.sessionReveal.value = release; + dataSource.setRevealedSession(resource); + let revealed = false; + try { + list.closeFind(); + await this.update(); + if (token.isCancellationRequested || this._store.isDisposed || this.sessionReveal.value !== release) { + return undefined; + } + this._isProgrammaticCollapseChange = true; + try { + for (const child of list.getNode(this.agentSessionsService.model).children) { + if (isAgentSessionSection(child.element) && child.element.sessions.some(session => isEqual(session.resource, resource))) { + await list.expand(child.element); + } + } + } finally { this._isProgrammaticCollapseChange = false; } + if (token.isCancellationRequested || this._store.isDisposed || this.sessionReveal.value !== release || !this.reveal(resource)) { + return undefined; + } + revealed = true; + return release; + } finally { + if (!revealed) { + release.dispose(); + } + } + } + + /** Resolves a row again after virtualization or a list refresh. */ + getSessionElement(resource: URI): HTMLElement | undefined { + return this.sessionRenderer?.getSessionElement(resource); + } + reveal(sessionResource: URI): boolean { if (!this.sessionsList) { return false; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index 516b994fe75286..f9cf3af3afbac6 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -13,7 +13,7 @@ import { ITreeCompressionDelegate } from '../../../../../base/browser/ui/tree/as import { ICompressedTreeNode } from '../../../../../base/browser/ui/tree/compressedObjectTreeModel.js'; import { ICompressibleKeyboardNavigationLabelProvider, ICompressibleTreeRenderer } from '../../../../../base/browser/ui/tree/objectTree.js'; import { ITreeNode, ITreeElementRenderDetails, IAsyncDataSource, ITreeSorter, ITreeDragAndDrop, ITreeDragOverReaction } from '../../../../../base/browser/ui/tree/tree.js'; -import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { AgentSessionSection, AgentSessionStatus, getAgentChangesSummary, hasValidDiff, IAgentSession, IAgentSessionSection, IAgentSessionShowLess, IAgentSessionShowMore, IAgentSessionsModel, isAgentSession, isAgentSessionChild, isAgentSessionSection, isAgentSessionShowLess, isAgentSessionShowMore, isAgentSessionsModel, isSessionInProgressStatus } from './agentSessionsModel.js'; import { IconLabel } from '../../../../../base/browser/ui/iconLabel/iconLabel.js'; import { ThemeIcon, themeColorFromId } from '../../../../../base/common/themables.js'; @@ -248,6 +248,12 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre readonly templateId = AgentSessionRenderer.TEMPLATE_ID; private readonly sessionHover = this._register(new MutableDisposable()); + private readonly renderedElements = new Map(); + + /** Returns only a currently rendered row owned by this list renderer. */ + getSessionElement(resource: URI): HTMLElement | undefined { + return this.renderedElements.get(resource.toString()); + } private readonly _onDidChangeItemHeight = this._register(new Emitter()); readonly onDidChangeItemHeight: Event = this._onDidChangeItemHeight.event; @@ -346,6 +352,13 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre // Clear old state template.elementDisposable.clear(); + const resource = session.element.resource.toString(); + this.renderedElements.set(resource, template.element); + template.elementDisposable.add(toDisposable(() => { + if (this.renderedElements.get(resource) === template.element) { + this.renderedElements.delete(resource); + } + })); template.diffAddedSpan.textContent = ''; template.diffRemovedSpan.textContent = ''; template.badge.textContent = ''; @@ -1232,6 +1245,12 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou readonly onDidExpandRepositoryGroup: Event = this._onDidExpandRepositoryGroup.event; private readonly expandedRepositoryGroups = new Set(); + private revealedSession: URI | undefined; + + /** Temporarily includes an explicitly revealed session without changing saved filters or caps. */ + setRevealedSession(resource: URI | undefined): void { + this.revealedSession = resource; + } constructor( private readonly filter: IAgentSessionsFilter | undefined, @@ -1284,7 +1303,8 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou if (isAgentSessionsModel(element)) { // Apply filter if configured - let filteredSessions = element.sessions.filter(session => !this.filter?.exclude(session)); + const revealed = element.sessions.find(session => session.resource.toString() === this.revealedSession?.toString()); + let filteredSessions = element.sessions.filter(session => session === revealed || !this.filter?.exclude(session)); // Apply sorter unless we group into sections or we are to limit results const limitResultsCount = this.filter?.limitResults?.(); @@ -1295,6 +1315,9 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou // Apply limiter if configured (requires sorting) if (typeof limitResultsCount === 'number') { filteredSessions = filteredSessions.slice(0, limitResultsCount); + if (revealed && !filteredSessions.includes(revealed)) { + filteredSessions.push(revealed); + } } // Callback results count @@ -1314,7 +1337,7 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou else if (isAgentSessionSection(element)) { const isCappingEnabled = this.repositoryGroupLimit && this.filter?.getExcludes().repositoryGroupCapped; if (isCappingEnabled && element.section === AgentSessionSection.Repository && element.sessions.length > this.repositoryGroupLimit) { - if (!this.expandedRepositoryGroups.has(element.label)) { + if (!this.expandedRepositoryGroups.has(element.label) && !element.sessions.some(session => session.resource.toString() === this.revealedSession?.toString())) { // Collapsed: show limited sessions + "show more" const visible = element.sessions.slice(0, this.repositoryGroupLimit); const remainingCount = element.sessions.length - this.repositoryGroupLimit; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDiscoveryPage.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDiscoveryPage.ts index 3b581d156c3be8..70a08f67950b04 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDiscoveryPage.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDiscoveryPage.ts @@ -9,6 +9,7 @@ import { renderFormattedText } from '../../../../../base/browser/formattedTextRe import { alert, status } from '../../../../../base/browser/ui/aria/aria.js'; import { Button } from '../../../../../base/browser/ui/button/button.js'; import { IListRenderer, IListVirtualDelegate } from '../../../../../base/browser/ui/list/list.js'; +import { createPixelSpinner } from '../../../../../base/browser/ui/pixelSpinner/pixelSpinner.js'; import { ProgressBar } from '../../../../../base/browser/ui/progressbar/progressbar.js'; import { DomScrollableElement } from '../../../../../base/browser/ui/scrollbar/scrollableElement.js'; import { Action, IAction, Separator, SubmenuAction } from '../../../../../base/common/actions.js'; @@ -346,6 +347,8 @@ class DiscoveryResultRenderer implements IListRenderer>(); @@ -426,6 +440,7 @@ export class AICustomizationManagementEditor extends EditorPane { private mcpDetailBackButton: HTMLButtonElement | undefined; private mcpDetailBackHover: IManagedHover | undefined; private mcpDetailOrigin: CustomizationDetailBaseOrigin | undefined; + private mcpMigrationDetailActions: IMigrationDetailActions | undefined; private readonly mcpDetailDisposables = this._register(new DisposableStore()); private readonly mcpDetailScrollUpdate = this._register(new MutableDisposable()); @@ -1185,6 +1200,8 @@ export class AICustomizationManagementEditor extends EditorPane { this.showEmbeddedMcpDetail(server); })); + this.editorDisposables.add(this.mcpListWidget.onDidUpdateServerDetail(() => this.refreshEmbeddedMcpDetail())); + this.editorDisposables.add(this.mcpListWidget.onDidRequestShowPlugin(item => { this.showPluginDetail(item); })); @@ -2060,6 +2077,7 @@ export class AICustomizationManagementEditor extends EditorPane { } private renderCustomizationMigrationDashboardState(): void { + this.updateMigrationDetailActions(); if (!this.migrationDashboard) { return; } @@ -2110,6 +2128,8 @@ export class AICustomizationManagementEditor extends EditorPane { item.promptType, storage, storage === PromptsStorage.local, + false, + { item, storage }, ); return; } @@ -2136,7 +2156,7 @@ export class AICustomizationManagementEditor extends EditorPane { compatibilityId: item.mcpServerId, source: { uri: item.resource }, }; - await this.showEmbeddedMcpDetail(detail, { kind: 'migration' }); + await this.showEmbeddedMcpDetail(detail, { kind: 'migration' }, { item, storage }); } private setMigrationDashboardItemSelected(item: ICustomizationMigrationDashboardItem, selected: boolean): void { @@ -2162,33 +2182,20 @@ export class AICustomizationManagementEditor extends EditorPane { private showMigrationItemActions(item: ICustomizationMigrationDashboardItem, _storage: PromptsStorage, anchor: HTMLElement): void { const disposables = new DisposableStore(); - const match = this.findMigrationCandidate(item); - const mcpServer = this.findMigrationMcpWorkbenchServer(item); + const actionState = this.getMigrationItemActionState(item); const migrate = disposables.add(new Action( 'customizationMigration.migrateItem', localize('migrateThisCustomization', "Migrate This Item"), undefined, - match !== undefined && !this.customizationMigrationInProgress, - async () => { - if (!match) { - return; - } - this.customizationMigrationTelemetryService.migrationClicked(match.category.migrationType, 1, this.migrationFlowId); - await this.migrateSelectedCustomizations(match.category, [match.candidate]); - }, + actionState.canMigrate, + () => this.migrateMigrationItem(item), )); const deleteAction = disposables.add(new Action( 'customizationMigration.deleteItem', localize('deleteCustomization', "Delete"), undefined, - (match !== undefined && !isMcpServerCustomizationMigrationCandidate(match.candidate)) || mcpServer !== undefined, - async () => { - if (match && !isMcpServerCustomizationMigrationCandidate(match.candidate)) { - await this.deleteCustomizationFile(match.candidate); - } else if (mcpServer) { - await this.deleteMigrationMcpServer(item.label, mcpServer); - } - }, + actionState.canDelete, + () => this.deleteMigrationItem(item), )); this.contextMenuService.showContextMenu({ getAnchor: () => anchor, @@ -2202,6 +2209,33 @@ export class AICustomizationManagementEditor extends EditorPane { }); } + private getMigrationItemActionState(item: ICustomizationMigrationDashboardItem): { readonly isMigratable: boolean; readonly canMigrate: boolean; readonly canDelete: boolean } { + const match = this.findMigrationCandidate(item); + return { + isMigratable: match !== undefined, + canMigrate: match !== undefined && !this.customizationMigrationInProgress, + canDelete: (match !== undefined && !isMcpServerCustomizationMigrationCandidate(match.candidate)) || this.findMigrationMcpWorkbenchServer(item) !== undefined, + }; + } + + private async migrateMigrationItem(item: ICustomizationMigrationDashboardItem): Promise { + const match = this.findMigrationCandidate(item); + if (!match || this.customizationMigrationInProgress) { + return; + } + this.customizationMigrationTelemetryService.migrationClicked(match.category.migrationType, 1, this.migrationFlowId); + await this.migrateSelectedCustomizations(match.category, [match.candidate]); + } + + private async deleteMigrationItem(item: ICustomizationMigrationDashboardItem): Promise { + const match = this.findMigrationCandidate(item); + if (match && !isMcpServerCustomizationMigrationCandidate(match.candidate)) { + return this.deleteCustomizationFile(match.candidate); + } + const mcpServer = this.findMigrationMcpWorkbenchServer(item); + return mcpServer ? this.deleteMigrationMcpServer(item.label, mcpServer) : false; + } + private findMigrationMcpWorkbenchServer(item: ICustomizationMigrationDashboardItem) { if (!item.mcpServerId || !item.resource) { return undefined; @@ -2213,7 +2247,7 @@ export class AICustomizationManagementEditor extends EditorPane { ); } - private async deleteMigrationMcpServer(label: string, server: IWorkbenchMcpServer): Promise { + private async deleteMigrationMcpServer(label: string, server: IWorkbenchMcpServer): Promise { const confirmation = await this.dialogService.confirm({ message: localize('confirmDeleteMigrationMcpServer', "Delete MCP server '{0}'?", label), detail: localize('confirmDeleteMigrationMcpServerDetail', "This removes the server from its current MCP configuration. This action cannot be undone."), @@ -2221,15 +2255,16 @@ export class AICustomizationManagementEditor extends EditorPane { type: 'warning', }); if (!confirmation.confirmed) { - return; + return false; } await this.mcpWorkbenchService.uninstall(server); await this.refreshCustomizationMigrationInfo(); + return true; } - private async deleteCustomizationFile(customization: MigratableConfiguration): Promise { + private async deleteCustomizationFile(customization: MigratableConfiguration): Promise { if (this.customizationMigrationInProgress) { - return; + return false; } const fileName = customization.name ?? basename(customization.uri); const confirmation = await this.dialogService.confirm({ @@ -2239,7 +2274,7 @@ export class AICustomizationManagementEditor extends EditorPane { type: 'warning', }); if (!confirmation.confirmed) { - return; + return false; } const useTrash = this.fileService.hasCapability(customization.uri, FileSystemProviderCapabilities.Trash); await this.fileService.del(customization.uri, { useTrash }); @@ -2250,6 +2285,7 @@ export class AICustomizationManagementEditor extends EditorPane { } } await this.refreshCustomizationMigrationInfo(); + return true; } private async ignoreMigrationCategory(id: CustomizationMigrationCategoryId, storage: PromptsStorage): Promise { @@ -3848,6 +3884,8 @@ export class AICustomizationManagementEditor extends EditorPane { void this.openCurrentCustomizationFile(); })); + this.editorMigrationDetailActions = this.createMigrationDetailActions(editorHeader); + this.editorModeButton = DOM.append(editorHeader, $('button.editor-mode-button')); this.editorModeButton.type = 'button'; this.editorModeButton.setAttribute('aria-pressed', 'false'); @@ -3946,11 +3984,12 @@ export class AICustomizationManagementEditor extends EditorPane { } } - private async showEmbeddedEditor(uri: URI, displayName: string, promptType: PromptsType, source: AICustomizationSource, isWorkspaceFile = false, isReadOnly = false): Promise { + private async showEmbeddedEditor(uri: URI, displayName: string, promptType: PromptsType, source: AICustomizationSource, isWorkspaceFile = false, isReadOnly = false, migrationDetail?: IMigrationDetailContext): Promise { if (!hasReadableCustomizationContent(uri)) { return; } + this.currentMigrationDetail = migrationDetail; this.prepareCustomizationView(uri, displayName, promptType, source, isWorkspaceFile, isReadOnly, false); try { @@ -4053,6 +4092,7 @@ export class AICustomizationManagementEditor extends EditorPane { this.resetEditorSaveIndicator(); this.updateEditorActionButton(); this.updateEditorDisplayMode(); + this.updateMigrationDetailActions(); this.updateContentVisibility(); } @@ -4071,6 +4111,7 @@ export class AICustomizationManagementEditor extends EditorPane { const detailOrigin = this.currentCustomizationDetail ? this.customizationDetailOrigin : undefined; this.editorReturnViewMode = 'list'; this.customizationDetailOrigin = undefined; + this.currentMigrationDetail = undefined; const fileUri = this.currentEditingUri; const backgroundSaveRequest = this.createExistingCustomizationSaveRequest(); if (backgroundSaveRequest) { @@ -4104,6 +4145,7 @@ export class AICustomizationManagementEditor extends EditorPane { this.resetEditorSaveIndicator(); this.updateEditorActionButton(); this.updateEditorDisplayMode(); + this.updateMigrationDetailActions(); this.embeddedEditor?.setModel(null); if (detailOrigin) { void this.restoreDetailOrigin(detailOrigin); @@ -4931,6 +4973,12 @@ export class AICustomizationManagementEditor extends EditorPane { this.embeddedMcpDetail = this.editorDisposables.add(this.instantiationService.createInstance(EmbeddedMcpServerDetail, detailBody, { openMigrationPage: () => void this.startCustomizationMigration(CustomizationMigrationCategoryId.McpServers), })); + const firstTitleAction = this.embeddedMcpDetail.titleActions.firstChild; + this.mcpMigrationDetailActions = this.createMigrationDetailActions(this.embeddedMcpDetail.titleActions); + if (firstTitleAction) { + this.embeddedMcpDetail.titleActions.insertBefore(this.mcpMigrationDetailActions.delete.element, firstTitleAction); + this.embeddedMcpDetail.titleActions.insertBefore(this.mcpMigrationDetailActions.migrate.element, firstTitleAction); + } this.editorDisposables.add(this.embeddedMcpDetail.onDidChangeContent(() => { this.scheduleDetailScrollableScan(this.mcpDetailScrollable, this.mcpDetailScrollUpdate); })); @@ -4948,7 +4996,7 @@ export class AICustomizationManagementEditor extends EditorPane { })); } - private async showEmbeddedMcpDetail(server: IMcpServerDetailInput, origin: CustomizationDetailBaseOrigin = this.getCurrentDetailBaseOrigin()): Promise { + private async showEmbeddedMcpDetail(server: IMcpServerDetailInput, origin: CustomizationDetailBaseOrigin = this.getCurrentDetailBaseOrigin(), migrationDetail?: IMigrationDetailContext): Promise { if (!this.embeddedMcpDetail) { this.createEmbeddedMcpDetail(); } @@ -4957,6 +5005,7 @@ export class AICustomizationManagementEditor extends EditorPane { } this.mcpDetailOrigin = origin; + this.currentMigrationDetail = migrationDetail; this.viewMode = 'mcpDetail'; this.mcpDetailInput = server; this.updateContentVisibility(); @@ -4969,6 +5018,7 @@ export class AICustomizationManagementEditor extends EditorPane { this.mcpDetailBackButton?.setAttribute('aria-label', label); this.mcpDetailBackHover?.update(label); this.refreshMcpDetailMigrationState(); + this.updateMigrationDetailActions(); void this.refreshCustomizationMigrationInfo(); if (this.dimension) { @@ -4980,12 +5030,28 @@ export class AICustomizationManagementEditor extends EditorPane { private goBackFromMcpDetail(): void { const origin = this.mcpDetailOrigin; this.mcpDetailOrigin = undefined; + this.currentMigrationDetail = undefined; this.mcpDetailDisposables.clear(); this.mcpDetailInput = undefined; this.embeddedMcpDetail?.clearInput(); + this.updateMigrationDetailActions(); void this.restoreDetailOrigin(origin); } + /** Keeps an open MCP detail in sync when the agent host reports new metadata for its server. */ + private refreshEmbeddedMcpDetail(): void { + const current = this.mcpDetailInput; + if (!current || !this.embeddedMcpDetail || this.viewMode !== 'mcpDetail') { + return; + } + const updated = this.mcpListWidget?.getUpdatedServerDetail(current.id); + if (!updated) { + return; + } + this.mcpDetailInput = updated; + this.embeddedMcpDetail.updateInput(updated); + } + private refreshMcpDetailMigrationState(): void { if (!this.mcpDetailInput || !this.embeddedMcpDetail || this.viewMode !== 'mcpDetail') { return; @@ -4998,6 +5064,86 @@ export class AICustomizationManagementEditor extends EditorPane { this.embeddedMcpDetail.setMigratable(migratable); } + private createMigrationDetailActions(parent: HTMLElement): IMigrationDetailActions { + const deleteAction = this.editorDisposables.add(new Button(parent, { + ...defaultButtonStyles, + secondary: true, + buttonSecondaryBackground: 'transparent', + buttonSecondaryForeground: 'var(--vscode-foreground)', + buttonSecondaryHoverBackground: 'var(--vscode-toolbar-hoverBackground)', + buttonSecondaryBorder: 'transparent', + ariaLabel: localize('deleteCustomizationActionAriaLabel', "Delete customization"), + })); + deleteAction.element.classList.add('migration-detail-action'); + deleteAction.label = localize('delete', "Delete"); + this.editorDisposables.add(deleteAction.onDidClick(() => { + void this.deleteCurrentMigrationDetail().catch(error => this.notificationService.error( + localize('deleteCustomizationFailed', "Could not delete the customization: {0}", getErrorMessage(error)) + )); + })); + + const migrate = this.editorDisposables.add(new Button(parent, { + ...defaultButtonStyles, + ariaLabel: localize('migrateCustomization', "Migrate customization"), + })); + migrate.element.classList.add('migration-detail-action'); + migrate.label = localize('migrate', "Migrate"); + this.editorDisposables.add(migrate.onDidClick(() => { + void this.migrateCurrentMigrationDetail().catch(error => this.notificationService.error( + localize('migrateCustomizationFailed', "Could not migrate the customization: {0}", getErrorMessage(error)) + )); + })); + + const actions = { migrate, delete: deleteAction }; + this.updateMigrationDetailActionButtons(actions, undefined); + return actions; + } + + private updateMigrationDetailActions(): void { + this.updateMigrationDetailActionButtons(this.editorMigrationDetailActions, this.viewMode === 'editor' ? this.currentMigrationDetail : undefined); + this.updateMigrationDetailActionButtons(this.mcpMigrationDetailActions, this.viewMode === 'mcpDetail' ? this.currentMigrationDetail : undefined); + } + + private updateMigrationDetailActionButtons(actions: IMigrationDetailActions | undefined, detail: IMigrationDetailContext | undefined): void { + if (!actions) { + return; + } + actions.delete.element.style.display = detail ? '' : 'none'; + if (!detail) { + actions.migrate.element.style.display = 'none'; + return; + } + const actionState = this.getMigrationItemActionState(detail.item); + actions.migrate.element.style.display = actionState.isMigratable ? '' : 'none'; + actions.migrate.enabled = actionState.canMigrate; + actions.delete.enabled = actionState.canDelete; + actions.migrate.element.setAttribute('aria-label', localize('migrateNamedCustomization', "Migrate {0}", detail.item.label)); + actions.delete.element.setAttribute('aria-label', localize('deleteNamedCustomization', "Delete {0}", detail.item.label)); + } + + private async migrateCurrentMigrationDetail(): Promise { + const detail = this.currentMigrationDetail; + if (!detail) { + return; + } + const saveRequest = this.viewMode === 'editor' ? this.createExistingCustomizationSaveRequest() : undefined; + if (saveRequest) { + await this.saveExistingCustomization(saveRequest); + this._editorContentChanged = false; + this.resetEditorSaveIndicator(); + } + await this.migrateMigrationItem(detail.item); + } + + private async deleteCurrentMigrationDetail(): Promise { + const detail = this.currentMigrationDetail; + if (!detail || !await this.deleteMigrationItem(detail.item) || this.currentMigrationDetail !== detail) { + return; + } + this._editorContentChanged = false; + this.showCustomizationMigrationDashboard(); + } + //#endregion //#region Embedded Connector Detail diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationAccessibility.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationAccessibility.ts index f264956d7ed32a..411fbb2d1fb072 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationAccessibility.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationAccessibility.ts @@ -35,7 +35,7 @@ class CustomizationMigrationAccessibleView implements IAccessibleViewImplementat { type: this.type }, () => this.type === AccessibleViewType.Help ? [ localize('migrationHelpOverview', "The migrations tree groups each migration type by workspace or user. Expand a group to inspect each customization, its source location, and its destination."), - localize('migrationHelpNavigation', "Use the arrow keys to move through and expand the tree. Use each row's checkbox to include or exclude it. Open a customization to view its editor or details. The More Actions menu can migrate or delete one item."), + localize('migrationHelpNavigation', "Use the arrow keys to move through and expand the tree. Use each row's checkbox to include or exclude it. Open a customization to view its editor or details. Use the header actions or the row's More Actions menu to migrate or delete one item."), localize('migrationHelpAgent', "Use Migrate to have VS Code update the selected customizations. Migrate with Agent closes Agent Customizations and focuses a guided migration in chat for the selected harness. Agent migration uses credits."), localize('migrationHelpActions', "Migrate applies to the selected items in one group and shows the planned source and destination changes before modifying files. Ignore permanently hides a group. Show Ignored Migrations restores hidden groups."), localize('migrationHelpLocations', "Activate a destination path to change the destination. Workspace MCP servers migrate to the root .mcp.json. User MCP servers migrate to mcp-config.json in Copilot home. Disabled user servers may become enabled after migration."), diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMcpServerDetail.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMcpServerDetail.ts index c24954b8271867..6d1bcbb30b39ab 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMcpServerDetail.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMcpServerDetail.ts @@ -11,7 +11,8 @@ import { Emitter } from '../../../../../base/common/event.js'; import { findNodeAtLocation, parseTree } from '../../../../../base/common/json.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { autorun, IObservable } from '../../../../../base/common/observable.js'; -import { basename } from '../../../../../base/common/resources.js'; +import { basename, isEqual } from '../../../../../base/common/resources.js'; +import { equals } from '../../../../../base/common/objects.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { CodeEditorWidget } from '../../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; @@ -20,12 +21,13 @@ import { ILanguageService } from '../../../../../editor/common/languages/languag import { ITextModel } from '../../../../../editor/common/model.js'; import { IModelService } from '../../../../../editor/common/services/model.js'; import { localize } from '../../../../../nls.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IMcpServerConfiguration } from '../../../../../platform/mcp/common/mcpPlatformTypes.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; -import { CustomizationMarketplaceIcon } from '../../../../../platform/customizationMarketplace/common/customizationMarketplaceService.js'; +import { CustomizationMarketplaceIcon, isCustomizationMarketplaceIconEqual } from '../../../../../platform/customizationMarketplace/common/customizationMarketplaceService.js'; import { defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { IThemeService } from '../../../../../platform/theme/common/themeService.js'; import { getSimpleEditorOptions } from '../../../codeEditor/browser/simpleEditorOptions.js'; @@ -55,6 +57,28 @@ export interface IMcpServerDetailInput { readonly uri: URI; readonly range?: IRange; }; + /** Where the server comes from when it has no {@link source} file, such as an extension or the agent itself. */ + readonly provenance?: IMcpServerProvenance; + /** Explains why there is no definition to show, for a server whose definition the client cannot read. */ + readonly definitionUnavailable?: IMcpServerDefinitionUnavailable; +} + +/** Why an MCP server has no definition to show, and where it can be controlled instead. */ +export interface IMcpServerDefinitionUnavailable { + /** Localized explanation shown in place of the definition. */ + readonly message: string; + /** Setting that controls the server, offered as a link to the Settings editor. */ + readonly settingId?: string; +} + +/** Describes where an MCP server comes from when there is no configuration file to open. */ +export interface IMcpServerProvenance { + /** Short localized label, for example "Built-in: Copilot" or "Extension: GitHub Copilot Chat". */ + readonly label: string; + /** Localized accessible name of the link that reveals the provider; present with {@link open}. */ + readonly ariaLabel?: string; + /** Reveals the provider, for example its extension details. */ + open?(): void; } export interface IMcpServerDetailOptions { @@ -100,6 +124,7 @@ export class EmbeddedMcpServerDetail extends Disposable { private readonly iconEl: HTMLElement; private readonly nameEl: HTMLElement; private readonly pathEl: HTMLAnchorElement; + private readonly titleActionsEl: HTMLElement; private readonly editConfigurationButton: Button; private readonly bodyEl: HTMLElement; private readonly diagnosticsEmpty: HTMLElement; @@ -113,6 +138,7 @@ export class EmbeddedMcpServerDetail extends Disposable { private readonly diagnosticDisposables = this._register(new DisposableStore()); private readonly iconDisposables = this._register(new DisposableStore()); private readonly migrationLinkListener = this._register(new MutableDisposable()); + private readonly definitionSettingsLinkListener = this._register(new MutableDisposable()); private readonly emptyEl: HTMLElement; private current: IMcpServerDetailInput | undefined; @@ -135,6 +161,7 @@ export class EmbeddedMcpServerDetail extends Disposable { @ICustomizationHarnessService private readonly customizationHarnessService: ICustomizationHarnessService, @INotificationService private readonly notificationService: INotificationService, @IThemeService private readonly themeService: IThemeService, + @ICommandService private readonly commandService: ICommandService, ) { super(); @@ -146,9 +173,9 @@ export class EmbeddedMcpServerDetail extends Disposable { const headerText = DOM.append(this.headerEl, $('.editor-item-info')); this.nameEl = DOM.append(headerText, $('.editor-item-name')); this.pathEl = DOM.append(headerText, $('a.editor-item-path')) as HTMLAnchorElement; - const headerActions = DOM.append(this.headerEl, $('.embedded-detail-title-actions')); + this.titleActionsEl = DOM.append(this.headerEl, $('.embedded-detail-title-actions')); const editConfigurationLabel = localize('editMcpConfiguration', "Edit Configuration"); - this.editConfigurationButton = this._register(new Button(headerActions, { + this.editConfigurationButton = this._register(new Button(this.titleActionsEl, { ...defaultButtonStyles, secondary: true, ariaLabel: editConfigurationLabel, @@ -156,15 +183,17 @@ export class EmbeddedMcpServerDetail extends Disposable { this.editConfigurationButton.label = editConfigurationLabel; this._register(this.editConfigurationButton.onDidClick(() => void this.editConfiguration())); this._register(DOM.addDisposableListener(this.pathEl, DOM.EventType.CLICK, event => { - const source = this.current?.source; - if (!source) { - return; + const server = this.current; + if (server?.source) { + event.preventDefault(); + void this.editorService.openEditor({ + resource: server.source.uri, + options: { selection: server.source.range, pinned: true }, + }); + } else if (server?.provenance?.open) { + event.preventDefault(); + server.provenance.open(); } - event.preventDefault(); - void this.editorService.openEditor({ - resource: source.uri, - options: { selection: source.range, pinned: true }, - }); })); this.bodyEl = DOM.append(this.root, $('.mcp-detail-body')); @@ -221,12 +250,34 @@ export class EmbeddedMcpServerDetail extends Disposable { return this.leadingSlotEl; } + get titleActions(): HTMLElement { + return this.titleActionsEl; + } + setInput(server: IMcpServerDetailInput): void { this.current = server; this.bindDiagnostics(); this.renderItem(); } + /** + * Replaces the current server's presentation (label, source, provenance, definition) after the host + * reports new metadata, keeping migration state and leaving the view untouched when nothing changed. + */ + updateInput(server: IMcpServerDetailInput): void { + const current = this.current; + if (!current || current.id !== server.id) { + return; + } + const next: IMcpServerDetailInput = { ...server, migratable: current.migratable }; + if (isSameMcpServerDetailPresentation(current, next)) { + return; + } + this.current = next; + this.bindDiagnostics(); + this.renderItem(); + } + setMigratable(migratable: boolean): void { if (!this.current || this.current.migratable === migratable) { return; @@ -260,29 +311,14 @@ export class EmbeddedMcpServerDetail extends Disposable { this.renderIcon(); if (!server) { this.nameEl.textContent = ''; - this.pathEl.textContent = ''; - this.pathEl.removeAttribute('href'); - this.pathEl.removeAttribute('aria-label'); - this.pathEl.classList.remove('source-link'); - this.editConfigurationButton.element.style.display = 'none'; + this.renderSource(undefined); this.setDefinition(undefined); this.definitionEmptyEl.style.display = 'none'; return; } this.nameEl.textContent = server.label || server.name; - const sourceLabel = server.source ? basename(server.source.uri) : 'mcp.json'; - this.pathEl.textContent = sourceLabel; - this.pathEl.classList.toggle('source-link', !!server.source); - if (server.source) { - this.pathEl.href = '#'; - this.pathEl.setAttribute('aria-label', localize('openMcpServerSource', "Open {0}", sourceLabel)); - this.editConfigurationButton.element.style.display = ''; - } else { - this.pathEl.removeAttribute('href'); - this.pathEl.removeAttribute('aria-label'); - this.editConfigurationButton.element.style.display = 'none'; - } + this.renderSource(server); if (server.installState !== McpServerInstallState.Installed) { this.setDefinition(undefined, localize('mcpDefinitionAvailableAfterInstall', "Details are available after install when the MCP server can be inspected locally.")); } else if (server.config) { @@ -291,7 +327,7 @@ export class EmbeddedMcpServerDetail extends Disposable { this.setDefinition(undefined, localize('mcpDefinitionLoading', "Loading MCP server definition...")); void this.loadSourceDefinition(server, server.source, renderGeneration); } else { - this.setDefinition(undefined); + this.setDefinition(undefined, server.definitionUnavailable?.message, server.definitionUnavailable?.settingId); } } @@ -304,6 +340,28 @@ export class EmbeddedMcpServerDetail extends Disposable { } } + /** Shows the configuration file, or where the server comes from when it has none. */ + private renderSource(server: IMcpServerDetailInput | undefined): void { + const source = server?.source; + const provenance = source ? undefined : server?.provenance; + const label = source ? basename(source.uri) : provenance?.label ?? ''; + const linkLabel = source + ? localize('openMcpServerSource', "Open {0}", label) + : provenance?.open ? provenance.ariaLabel ?? label : undefined; + this.pathEl.textContent = label; + this.pathEl.style.display = label ? '' : 'none'; + this.pathEl.classList.toggle('provenance', provenance !== undefined); + this.pathEl.classList.toggle('source-link', linkLabel !== undefined); + if (linkLabel !== undefined) { + this.pathEl.href = '#'; + this.pathEl.setAttribute('aria-label', linkLabel); + } else { + this.pathEl.removeAttribute('href'); + this.pathEl.removeAttribute('aria-label'); + } + this.editConfigurationButton.element.style.display = source ? '' : 'none'; + } + private async editConfiguration(): Promise { const server = this.current; const source = server?.source; @@ -538,11 +596,11 @@ export class EmbeddedMcpServerDetail extends Disposable { } } - private setDefinition(definition: string | undefined, emptyMessage = localize('mcpDefinitionUnavailable', "No definition is available for this MCP server.")): void { + private setDefinition(definition: string | undefined, emptyMessage = localize('mcpDefinitionUnavailable', "No definition is available for this MCP server."), settingId?: string): void { const hasDefinition = definition !== undefined; this.definitionEditorContainer.style.display = hasDefinition ? '' : 'none'; this.definitionEmptyEl.style.display = hasDefinition ? 'none' : ''; - this.definitionEmptyEl.textContent = emptyMessage; + this.renderDefinitionEmpty(emptyMessage, hasDefinition ? undefined : settingId); if (this.currentDefinition === definition) { this._onDidChangeContent.fire(); @@ -568,6 +626,24 @@ export class EmbeddedMcpServerDetail extends Disposable { this._onDidChangeContent.fire(); } + /** Shows why there is no definition, with a link to the setting that controls the server when one exists. */ + private renderDefinitionEmpty(message: string, settingId: string | undefined): void { + this.definitionSettingsLinkListener.clear(); + DOM.clearNode(this.definitionEmptyEl); + DOM.append(this.definitionEmptyEl, $('span.mcp-detail-definition-message')).textContent = message; + if (!settingId) { + return; + } + const link = DOM.append(this.definitionEmptyEl, $('a.mcp-detail-definition-settings-link')) as HTMLAnchorElement; + link.href = '#'; + link.textContent = localize('mcpOpenServerSettings', "Open Settings"); + link.setAttribute('aria-label', localize('mcpOpenServerSettingsAria', "Open Settings for {0}", this.current?.label || this.current?.name || '')); + this.definitionSettingsLinkListener.value = DOM.addDisposableListener(link, DOM.EventType.CLICK, event => { + event.preventDefault(); + void this.commandService.executeCommand('workbench.action.openSettings', `@id:${settingId}`); + }); + } + private ensureDefinitionEditor(): CodeEditorWidget { if (!this.definitionEditor) { this.definitionEditor = this._register(this.instantiationService.createInstance( @@ -594,6 +670,26 @@ export class EmbeddedMcpServerDetail extends Disposable { } } +/** + * Whether two inputs for the same server present the same thing. Ignores the error observable and link + * callbacks, which are recreated on every rebuild without changing what the detail shows. + */ +function isSameMcpServerDetailPresentation(a: IMcpServerDetailInput, b: IMcpServerDetailInput): boolean { + return a.name === b.name + && a.label === b.label + && a.installState === b.installState + && a.compatibilityId === b.compatibilityId + && a.migratable === b.migratable + && equals(a.config, b.config) + && isEqual(a.source?.uri, b.source?.uri) + && equals(a.source?.range, b.source?.range) + && a.provenance?.label === b.provenance?.label + && a.provenance?.ariaLabel === b.provenance?.ariaLabel + && !a.provenance?.open === !b.provenance?.open + && equals(a.definitionUnavailable, b.definitionUnavailable) + && isCustomizationMarketplaceIconEqual(a.icon, b.icon); +} + function resolveCompatibilityState(resolved: boolean, compatibility: ICustomizationMcpServerCompatibility | undefined): McpDetailCompatibilityState { if (!resolved) { return { kind: 'checking', details: [] }; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts index 726cf158ae5907..0aef9e20d8a9ab 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts @@ -69,7 +69,7 @@ import { getErrorMessage, isCancellationError } from '../../../../../base/common import { status } from '../../../../../base/browser/ui/aria/aria.js'; import { Range } from '../../../../../editor/common/core/range.js'; import { IMcpServerConfiguration, McpServerType } from '../../../../../platform/mcp/common/mcpPlatformTypes.js'; -import { createWorkbenchMcpServerDetailInput, IMcpServerDetailInput } from './embeddedMcpServerDetail.js'; +import { createWorkbenchMcpServerDetailInput, IMcpServerDefinitionUnavailable, IMcpServerDetailInput, IMcpServerProvenance } from './embeddedMcpServerDetail.js'; import { createCustomizationCardPrimaryAction, CustomizationCardListController, getVirtualizedSectionMinimumHeight, layoutVirtualizedSections, setVirtualizedRowActionsTabbable, trackCustomizationCardPrimaryActionFocus } from './customizationCardList.js'; import { DomScrollableElement } from '../../../../../base/browser/ui/scrollbar/scrollableElement.js'; import { ScrollbarVisibility } from '../../../../../base/common/scrollable.js'; @@ -1026,24 +1026,33 @@ export function getMcpEntryGroup(entry: IMcpInstalledEntry): 'user' | 'workspace return entry.server.local?.scope === LocalMcpServerScope.Workspace ? 'workspace' : 'user'; } if (entry.type === 'builtin-item') { + if (entry.connector) { + return 'plugins'; + } if (entry.collectionId?.startsWith(PLUGIN_COLLECTION_PREFIX)) { return 'plugins'; } if (entry.extensionId) { - return 'extensions'; + return isCopilotExtension(entry.extensionId) ? 'builtin' : 'extensions'; } const collection = entry.localServer?.readDefinitions().get().collection; if (collection?.provenance === McpCollectionProvenance.ExternalConfiguration) { return McpCollectionDefinition.isWorkspaceDiscovered(collection) ? 'workspace' : 'user'; } } - switch (getActiveSessionServer(entry)?.source) { + const activeSessionServer = getActiveSessionServer(entry); + switch (activeSessionServer?.source) { case 'user': return 'user'; case 'workspace': return 'workspace'; case 'plugin': return 'plugins'; + case undefined: + // The agent host's own configuration applies to all of its sessions, like user configuration. + if (activeSessionServer?.hostConfiguration) { + return 'user'; + } } return 'builtin'; } @@ -1101,6 +1110,11 @@ function getMcpEntryLabel(element: IMcpServerItemEntry | IMcpSessionServerItemEn : element.server.label; } +/** The server's configuration key, which a managed catalog's display name can differ from. */ +function getMcpEntryName(element: IMcpInstalledEntry): string { + return element.type === 'session-server-item' ? element.server.name : getMcpEntryLabel(element); +} + function getMcpEntrySourceUri(element: IMcpInstalledEntry): URI | undefined { switch (element.type) { case 'server-item': @@ -1132,7 +1146,9 @@ function getMcpEntrySource(element: IMcpInstalledEntry, labelService: ILabelServ const extension = extensionsWorkbenchService.local.find(extension => ExtensionIdentifier.equals(extension.identifier.id, extensionId)); const extensionName = extension?.displayName || extensionId.value; return { - label: localize('fromExtension', "Extension: {0}", extensionName), + label: isCopilotExtension(extensionId) + ? localize('mcpSourceBuiltin', "Built-in: {0}", extensionName) + : localize('fromExtension', "Extension: {0}", extensionName), hover: localize('openExtensionDetails', "Open extension details for {0}", extensionName), ariaLabel: localize('openExtensionDetails', "Open extension details for {0}", extensionName), open: () => extensionsWorkbenchService.open(extensionId.value, { tab: ExtensionEditorTab.Features, feature: 'mcp' }), @@ -1156,6 +1172,59 @@ function getMcpEntryLabelWithSource(element: IMcpInstalledEntry, labelService: I : label; } +/** + * Describes where a host-reported server comes from when the host names no configuration file: + * bundled with the agent, supplied by the agent's managed catalog, contributed by a plugin the + * client may not know, configured in the agent host's own settings, or configured at a user or + * workspace level the host does not locate. + */ +export function getActiveSessionServerProvenance(server: AgentHostMcpServer, agentLabel: string): IMcpServerProvenance | undefined { + if (server.hostConfiguration && server.source !== 'builtin' && server.source !== 'managed') { + return { label: localize('mcpSourceAgentHost', "Agent host configuration") }; + } + switch (server.source) { + case 'builtin': + return { + label: server.sourcePluginName + ? localize('mcpSourceBuiltinPlugin', "Built-in plugin: {0}", server.sourcePluginName) + : localize('mcpSourceBuiltin', "Built-in: {0}", agentLabel), + }; + case 'managed': + return { label: localize('mcpSourceManaged', "Managed by {0}", agentLabel) }; + case 'plugin': + return server.sourcePluginName ? { label: localize('fromPlugin', "Plugin: {0}", server.sourcePluginName) } : undefined; + case 'workspace': + return { label: localize('mcpSourceWorkspace', "Workspace configuration") }; + case 'user': + return { label: localize('mcpSourceUser', "User configuration") }; + default: + return undefined; + } +} + +/** + * Explains why a host-reported server has no definition to show: the agent sets it up or manages it + * itself and never shares the definition. Links to the setting that controls the server only when the + * providing host declares one; the server's name alone says nothing about which host added it. + */ +export function getActiveSessionServerDefinitionUnavailable(server: AgentHostMcpServer, agentLabel: string): IMcpServerDefinitionUnavailable | undefined { + const settingId = server.controllingSettingId; + switch (server.source) { + case 'builtin': + return { + message: localize('mcpDefinitionBuiltin', "{0} configures this server automatically, so its definition can't be viewed or edited.", agentLabel), + ...(settingId ? { settingId } : {}), + }; + case 'managed': + return { + message: localize('mcpDefinitionManaged', "{0} manages this server, so its definition can't be viewed or edited.", agentLabel), + ...(settingId ? { settingId } : {}), + }; + default: + return undefined; + } +} + function getMcpServerCompatibilityId(element: IMcpInstalledEntry): string | undefined { if (element.type === 'session-server-item') { return undefined; @@ -1587,7 +1656,11 @@ function isConnectorMcpEntry(entry: IMcpInstalledEntry): entry is IMcpBuiltinIte return entry.type === 'builtin-item' && entry.connector !== undefined; } -export function createInstalledMcpServerDetailInput(entry: IMcpInstalledEntry, error?: IObservable): IMcpServerDetailInput { +/** + * Creates the detail view input for an installed row. `presentation` describes where the server comes + * from and why it has no definition; the detail shows it only when the row has no configuration file. + */ +export function createInstalledMcpServerDetailInput(entry: IMcpInstalledEntry, error?: IObservable, presentation?: Pick): IMcpServerDetailInput { if (entry.type === 'server-item') { const input = createWorkbenchMcpServerDetailInput(entry.server); return { @@ -1620,13 +1693,15 @@ export function createInstalledMcpServerDetailInput(entry: IMcpInstalledEntry, e return { id: getMcpRowKey(entry), - name: getMcpEntryLabel(entry), + name: getMcpEntryName(entry), label: getMcpEntryLabel(entry), installState: McpServerInstallState.Installed, - config: localDefinition ? getMcpServerConfiguration(localDefinition) : undefined, + config: localDefinition ? getMcpServerConfiguration(localDefinition) : activeSessionServer?.hostConfiguration, compatibilityId: localDefinition?.id, error, source: localSource ?? activeSessionSource, + ...(presentation?.provenance ? { provenance: presentation.provenance } : {}), + ...(presentation?.definitionUnavailable ? { definitionUnavailable: presentation.definitionUnavailable } : {}), }; } @@ -1663,6 +1738,10 @@ export class McpListWidget extends Disposable { private readonly _onDidSelectServer = this._register(new Emitter()); readonly onDidSelectServer = this._onDidSelectServer.event; + private readonly _onDidUpdateServerDetail = this._register(new Emitter()); + /** Fires when host-reported presentation may have changed; read updates with {@link getUpdatedServerDetail}. */ + readonly onDidUpdateServerDetail = this._onDidUpdateServerDetail.event; + private readonly _onDidChangeItemCount = this._register(new Emitter()); readonly onDidChangeItemCount = this._onDidChangeItemCount.event; @@ -2054,6 +2133,8 @@ export class McpListWidget extends Disposable { if (!hasSameMcpMembership(previousMembership, this.getInstalledEntryMembershipSignature())) { this.renderFilteredServers(); } + // A restored server is enriched by the host's inventory later, so an open detail must be refreshed. + this._onDidUpdateServerDetail.fire(); })); } @@ -2328,6 +2409,19 @@ export class McpListWidget extends Disposable { return id ? (reader ? this.mcpServerCompatibility.read(reader) : this.mcpServerCompatibility.get()).get(id) : undefined; } + /** + * Rebuilds the detail input of the installed server whose detail id is `id`, reflecting the latest + * host-reported presentation, or returns `undefined` when that server is no longer listed. + */ + getUpdatedServerDetail(id: string): IMcpServerDetailInput | undefined { + for (const { entry } of this.installedEntries) { + if (entry.type === 'server-item' ? entry.server.id === id : getMcpRowKey(entry) === id) { + return this.createInstalledMcpServerDetailInput(entry); + } + } + return undefined; + } + private createInstalledMcpServerDetailInput(entry: IMcpInstalledEntry): IMcpServerDetailInput { return createInstalledMcpServerDetailInput(entry, derived(this, reader => { const activeSessionServer = getActiveSessionServer(entry); @@ -2354,7 +2448,23 @@ export class McpListWidget extends Disposable { } const connectionState = localServer.connectionState.read(reader); return getMcpErrorMessage(connectionState.state, connectionState.state === McpConnectionState.Kind.Error ? connectionState.message : undefined); - })); + }), this.getMcpEntryDetailPresentation(entry)); + } + + /** + * Where a row's server comes from when it has no configuration file (its extension, its plugin, + * or the agent), and why the agent's own servers have no definition to show. + */ + private getMcpEntryDetailPresentation(entry: IMcpInstalledEntry): Pick { + const activeSessionServer = getActiveSessionServer(entry); + const agentLabel = this.customizationHarnessService.getActiveDescriptor().label; + const definitionUnavailable = activeSessionServer && getActiveSessionServerDefinitionUnavailable(activeSessionServer, agentLabel); + const source = getMcpEntrySource(entry, this.labelService, this.agentPluginService, this.extensionsWorkbenchService, plugin => this._onDidRequestShowPlugin.fire(createInstalledPluginItem(plugin))); + const open = source?.open; + const provenance = source && open + ? { label: source.label, ariaLabel: source.ariaLabel, open } + : activeSessionServer && getActiveSessionServerProvenance(activeSessionServer, agentLabel); + return { provenance, definitionUnavailable }; } private getMcpEntryAriaLabel(entry: IMcpInstalledEntry): IObservable { @@ -2538,7 +2648,7 @@ export class McpListWidget extends Disposable { const definitions = [ { id: 'user', label: localize('userMcpServersGroup', "User"), description: localize('userMcpServersGroupDescription', "MCP servers configured for your profile and available across workspaces."), icon: Codicon.account }, { id: 'workspace', label: localize('workspaceMcpServersGroup', "Workspace"), description: localize('workspaceMcpServersGroupDescription', "MCP servers configured by this workspace."), icon: Codicon.folder }, - { id: 'plugins', label: localize('pluginMcpServersGroup', "Plugins"), description: localize('pluginMcpServersGroupDescription', "MCP servers provided by installed plugins."), icon: Codicon.plug }, + { id: 'plugins', label: localize('pluginMcpServersGroup', "Plugins"), description: localize('pluginMcpServersGroupDescription', "MCP servers provided by installed plugins or connected connectors."), icon: Codicon.plug }, { id: 'extensions', label: localize('extensionMcpServersGroup', "Extensions"), description: localize('extensionMcpServersGroupDescription', "MCP servers provided by installed extensions."), icon: Codicon.extensions }, { id: 'builtin', label: localize('builtinMcpServersGroup', "Built-In"), description: localize('builtinMcpServersGroupDescription', "MCP servers built into the application or active agent host."), icon: mcpServerIcon }, { id: 'available', label: localize('availableMcpServersSection', "Available"), description: localize('availableMcpServersSectionDescription', "Browse and install MCP servers from the marketplace."), icon: Codicon.globe }, @@ -3032,7 +3142,7 @@ export class McpListWidget extends Disposable { const collectionSources = new Map(this.mcpRegistry.collections.get().map(c => [c.id, c.source])); const pluginServers: Array<{ server: IMcpServer; activeSessionServer?: AgentHostMcpServer }> = []; const extensionServers: Array<{ server: IMcpServer; activeSessionServer?: AgentHostMcpServer; extensionId: ExtensionIdentifier }> = []; - const otherBuiltinServers: Array<{ server: IMcpServer; activeSessionServer?: AgentHostMcpServer }> = []; + const otherBuiltinServers: Array<{ server: IMcpServer; activeSessionServer?: AgentHostMcpServer; extensionId?: ExtensionIdentifier }> = []; for (const server of builtinServers) { const entry = { server, activeSessionServer: activeSessionMatcher.take(getRuntimeServerMatchKeys(server)) }; const source = collectionSources.get(server.collection.id); @@ -3041,7 +3151,8 @@ export class McpListWidget extends Disposable { } else if (source instanceof ExtensionIdentifier && !isCopilotExtension(source)) { extensionServers.push({ ...entry, extensionId: source }); } else { - otherBuiltinServers.push(entry); + // Copilot's servers stay built-in, but keep the extension so the UI can show where they come from. + otherBuiltinServers.push({ ...entry, extensionId: source instanceof ExtensionIdentifier ? source : undefined }); } } const regularMatchKeys = new Set([ @@ -3064,7 +3175,7 @@ export class McpListWidget extends Disposable { ...groups.flatMap(group => group.entries.map(entry => ({ entry }))), ...pluginServers.map(({ server, activeSessionServer }) => ({ entry: createBuiltinEntry(server, activeSessionServer) })), ...extensionServers.map(({ server, activeSessionServer, extensionId }) => ({ entry: createBuiltinEntry(server, activeSessionServer, extensionId) })), - ...otherBuiltinServers.map(({ server, activeSessionServer }) => ({ entry: createBuiltinEntry(server, activeSessionServer) })), + ...otherBuiltinServers.map(({ server, activeSessionServer, extensionId }) => ({ entry: createBuiltinEntry(server, activeSessionServer, extensionId) })), ...connectorMcpEntries.map(entry => ({ entry })), ...activeSessionBuiltinEntries.map(entry => ({ entry })), ]; @@ -3092,6 +3203,8 @@ export class McpListWidget extends Disposable { getActiveSessionServer(entry) ? 'session' : '', entry.type !== 'session-server-item' && entry.localServer ? 'local' : '', getMcpEntrySourceUri(entry)?.toString() ?? '', + // A host can report a display name after the row first renders. + getMcpEntryLabel(entry), ].join(':')).join('|'); } @@ -3342,6 +3455,14 @@ export class McpListWidget extends Disposable { } } + const sourceAction = plugin ? undefined : this.getBuiltinMcpServerSourceAction(entry); + if (sourceAction) { + if (actions.length > 0) { + actions.push(new Separator()); + } + actions.push(disposables.add(sourceAction)); + } + if (plugin) { if (actions.length > 0) { actions.push(new Separator()); @@ -3404,4 +3525,22 @@ export class McpListWidget extends Disposable { } return getServerItemContextMenuActions(groups, activeSessionServer, activeSessionLifecycleAction, agentHostEnablementActions); } + + /** Reveals where a built-in row comes from: its contributing extension, or its configuration file. */ + private getBuiltinMcpServerSourceAction(entry: IMcpBuiltinItemEntry): Action | undefined { + const extensionId = entry.extensionId; + if (extensionId) { + return new Action('mcpServer.showExtension', localize('showMcpServerExtension', "Show Extension"), undefined, true, async () => { + await this.extensionsWorkbenchService.open(extensionId.value, { tab: ExtensionEditorTab.Features, feature: 'mcp' }); + }); + } + const localServer = entry.localServer; + const definitions = localServer?.readDefinitions().get(); + if (!localServer || !(definitions?.server?.presentation?.origin ?? definitions?.collection?.presentation?.origin)) { + return undefined; + } + return new Action('mcpServer.showConfiguration', localize('showMcpServerConfiguration', "Show Configuration"), undefined, true, async () => { + await this.commandService.executeCommand(McpCommandIds.ShowConfiguration, localServer.collection.id, localServer.definition.id); + }); + } } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationDiscovery.css b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationDiscovery.css index 8b05f13560a786..08ba09694addc8 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationDiscovery.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationDiscovery.css @@ -96,19 +96,19 @@ margin-bottom: var(--vscode-spacing-size40); } -.customization-discovery-result-content.loading { - background: url('../../../../extensions/browser/media/loading.svg') center center no-repeat; -} - -.vs-dark .customization-discovery-result-content.loading { - background-image: url('../../../../extensions/browser/media/loading-dark.svg'); +.customization-discovery-result-content > .customization-discovery-result-loading { + display: none; + position: absolute; + inset: 0; + margin: auto; + color: var(--vscode-descriptionForeground); } -.hc-black .customization-discovery-result-content.loading { - background-image: url('../../../../extensions/browser/media/loading-hc.svg'); +.customization-discovery-result-content.loading > .customization-discovery-result-loading { + display: inline-grid; } -.customization-discovery-result-content.loading > * { +.customization-discovery-result-content.loading > :not(.customization-discovery-result-loading) { visibility: hidden; } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css index b174297d03760a..809e7be7422700 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css @@ -1936,6 +1936,23 @@ per-word capitalization does not survive translation. */ border-radius: var(--vscode-cornerRadius-small); } +.ai-customization-management-editor .embedded-mcp-detail .mcp-detail-definition-settings-link { + margin-inline-start: var(--vscode-spacing-size40); + color: var(--vscode-textLink-foreground); + text-decoration: none; +} + +.ai-customization-management-editor .embedded-mcp-detail .mcp-detail-definition-settings-link:hover, +.ai-customization-management-editor .embedded-mcp-detail .mcp-detail-definition-settings-link:active { + color: var(--vscode-textLink-activeForeground); + text-decoration: underline; +} + +.ai-customization-management-editor .embedded-mcp-detail .mcp-detail-definition-settings-link:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: var(--vscode-strokeThickness); +} + .ai-customization-management-editor .embedded-mcp-detail .embedded-detail-leading-slot:empty { display: none; } @@ -3867,6 +3884,11 @@ per-word capitalization does not survive translation. */ outline-offset: var(--vscode-strokeThickness); } +/* A provider name reads as prose rather than a file path, so it keeps the proportional description font. */ +.ai-customization-management-editor .embedded-mcp-detail .editor-item-path.provenance { + font-family: inherit; +} + .ai-customization-management-editor .editor-save-indicator { flex-shrink: 0; width: 16px; @@ -3903,6 +3925,11 @@ per-word capitalization does not survive translation. */ transition: background-color 0.1s ease, border-color 0.1s ease; } +.ai-customization-management-editor .migration-detail-action.monaco-button { + width: auto; + flex-shrink: 0; +} + .ai-customization-management-editor .editor-mode-button:hover { background: var(--vscode-button-secondaryHoverBackground); } diff --git a/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts index 15510a5a4b20d0..d794abe5fbd89e 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts @@ -20,6 +20,7 @@ import { IMarkdownString, MarkdownString } from '../../../../../base/common/html import { Iterable } from '../../../../../base/common/iterator.js'; import { KeyCode } from '../../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { ResourceMap } from '../../../../../base/common/map.js'; import { Schemas } from '../../../../../base/common/network.js'; import { basename, dirname } from '../../../../../base/common/path.js'; import { isEqual, joinPath } from '../../../../../base/common/resources.js'; @@ -81,6 +82,7 @@ import { IChatResponseResourceFileSystemProvider } from '../../common/widget/cha import { IChatContextService } from '../contextContrib/chatContextService.js'; import { IChatImageCarouselService } from '../chatImageCarouselService.js'; import { createChatImageHoverContent } from '../../../../browser/chatImagePreview.js'; +import { ChatImageReveal, IChatImageRevealOrigin } from './chatImageReveal.js'; const commonHoverOptions: Partial = { style: HoverStyle.Pointer, @@ -487,13 +489,18 @@ function getHoverContent(ariaLabel: string, attachment: ITerminalVariableEntry): } } +export interface IChatImageBase64Data { + readonly data: string; + readonly mimeType: string; +} + export class ImageAttachmentWidget extends AbstractChatAttachmentWidget { constructor( resource: URI | undefined, attachment: IChatRequestVariableEntry, currentLanguageModel: ILanguageModelChatMetadataAndIdentifier | undefined, - options: { shouldFocusClearButton: boolean; supportsDeletion: boolean; isCurrentInput?: boolean; showImageInHover?: boolean }, + options: { shouldFocusClearButton: boolean; supportsDeletion: boolean; isCurrentInput?: boolean; showImageInHover?: boolean; imagePresentation?: 'thumbnail' | 'inline'; imageReveal?: IChatImageRevealOrigin; imageDimensions?: ResourceMap; imageBase64Data?: IChatImageBase64Data }, container: HTMLElement, contextResourceLabels: ResourceLabels, @ICommandService commandService: ICommandService, @@ -501,7 +508,7 @@ export class ImageAttachmentWidget extends AbstractChatAttachmentWidget { @IConfigurationService configurationService: IConfigurationService, @IHoverService private readonly hoverService: IHoverService, @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, - @IInstantiationService instantiationService: IInstantiationService, + @IInstantiationService private readonly instantiationService: IInstantiationService, @ILabelService private readonly labelService: ILabelService, @IChatImageCarouselService private readonly chatImageCarouselService: IChatImageCarouselService, @IFileDialogService private readonly fileDialogService: IFileDialogService, @@ -545,17 +552,21 @@ export class ImageAttachmentWidget extends AbstractChatAttachmentWidget { const fullName = resource ? this.labelService.getUriLabel(resource) : (attachment.fullName || attachment.name); - const imageElements = this._register(new MutableDisposable()); - const renderImageElements = (buffer: Uint8Array) => { - imageElements.value = createImageElements(resource, attachment.name, fullName, this.element, buffer, attachment.id, this.hoverService, ariaLabel, currentLanguageModelName, clickHandler, this.currentLanguageModel, omittedState, options.isCurrentInput === true, options.showImageInHover ?? true); - // createImageElements resets the label; restore the deletion hint after each render. - this.element.ariaLabel = this.appendDeletionHint(ariaLabel); - }; - renderImageElements(imageData ?? new Uint8Array()); + if (options.imagePresentation === 'inline' && omittedState !== OmittedState.Full && omittedState !== OmittedState.ImageLimitExceeded && (!currentLanguageModel || modelSupportsVision(currentLanguageModel))) { + this._register(this.renderInlineImage(resource, attachment.name, fullName, options.imageBase64Data ?? imageData, ariaLabel, options.imageReveal, options.imageDimensions)); + } else { + const imageElements = this._register(new MutableDisposable()); + const renderImageElements = (buffer: Uint8Array) => { + imageElements.value = createImageElements(resource, attachment.name, fullName, this.element, buffer, attachment.id, this.hoverService, ariaLabel, currentLanguageModelName, clickHandler, this.currentLanguageModel, omittedState, options.isCurrentInput === true, options.showImageInHover ?? true); + // createImageElements resets the label; restore the deletion hint after each render. + this.element.ariaLabel = this.appendDeletionHint(ariaLabel); + }; + renderImageElements(imageData ?? new Uint8Array()); - // Hydrated attachments need disk bytes so the preview does not fall back to a generic file icon. - if (!imageData && resource && omittedState !== OmittedState.Full && omittedState !== OmittedState.ImageLimitExceeded) { - void this.loadImageBytes(resource, renderImageElements); + // Hydrated attachments need disk bytes so the preview does not fall back to a generic file icon. + if (!imageData && resource && omittedState !== OmittedState.Full && omittedState !== OmittedState.ImageLimitExceeded) { + void this.loadImageBytes(resource, renderImageElements); + } } this.attachSaveButton(resource, imageData, attachment.name, options.supportsDeletion); @@ -575,12 +586,104 @@ export class ImageAttachmentWidget extends AbstractChatAttachmentWidget { } } - private async loadImageBytes(resource: URI, render: (buffer: Uint8Array) => void): Promise { + private renderInlineImage(resource: URI | undefined, name: string, fullName: string, imageData: Uint8Array | IChatImageBase64Data | undefined, ariaLabel: string, imageReveal: IChatImageRevealOrigin | undefined, imageDimensions: ResourceMap | undefined): IDisposable { + const store = new DisposableStore(); + const image = dom.$('img.chat-attached-context-pill-image', { alt: '' }); + const status = dom.$('span.chat-attached-context-image-status', undefined, localize('chat.loadingImage', "Loading image...")); + const imageContainer = imageReveal ? dom.$('div', undefined, image) : image; + const preview = dom.append(this.element, dom.$('.chat-attached-context-pill', undefined, imageContainer, status)); + const reveal = imageReveal ? store.add(this.instantiationService.createInstance(ChatImageReveal, imageContainer, image, imageReveal)) : undefined; + if (reveal) { + dom.hide(status); + } + const hover = dom.$('.chat-attached-context-hover', { 'aria-label': ariaLabel }, dom.$('.chat-attached-context-url', undefined, fullName)); + const imageUrl = store.add(new MutableDisposable()); + const dimensions = resource && !imageReveal ? imageDimensions?.get(resource) : undefined; + if (dimensions) { + image.style.width = `${dimensions.width}px`; + image.style.aspectRatio = `${dimensions.width} / ${dimensions.height}`; + image.style.maxWidth = `min(100%, calc(var(--chat-generated-image-max-height) * ${dimensions.width} / ${dimensions.height}))`; + image.style.visibility = 'hidden'; + dom.hide(status); + } else { + dom.hide(image); + } + const clearSizeHint = () => { + image.style.width = ''; + image.style.aspectRatio = ''; + image.style.maxWidth = ''; + image.style.visibility = ''; + }; + this.element.ariaLabel = this.appendDeletionHint(ariaLabel); + this.element.setAttribute('aria-busy', 'true'); + store.add(this.hoverService.setupDelayedHover(this.element, { ...commonHoverOptions, content: hover })); + store.add(toDisposable(() => { + image.removeAttribute('src'); + preview.remove(); + })); + + const showError = (detail?: string) => { + if (store.isDisposed) { + return; + } + const message = localize('chat.imagePreviewLoadError', "Unable to load image: {0}", name); + this.element.classList.add('image-load-error'); + this.element.setAttribute('aria-busy', 'false'); + this.element.ariaLabel = this.appendDeletionHint(message); + status.textContent = message; + hover.textContent = detail ? localize('chat.imagePreviewLoadErrorDetails', "{0}\n{1}", message, detail) : message; + if (resource) { + imageDimensions?.delete(resource); + } + clearSizeHint(); + reveal?.dispose(); + dom.show(status); + dom.hide(image); + }; + store.add(dom.addDisposableListener(image, dom.EventType.LOAD, event => { + this.element.setAttribute('aria-busy', 'false'); + status.remove(); + if (resource && image.naturalWidth > 0 && image.naturalHeight > 0) { + imageDimensions?.set(resource, { width: image.naturalWidth, height: image.naturalHeight }); + } + clearSizeHint(); + dom.show(image); + reveal?.reveal(event.timeStamp); + })); + store.add(dom.addDisposableListener(image, dom.EventType.ERROR, () => showError())); + + const render = (buffer: Uint8Array) => { + if (store.isDisposed) { + return; + } + if (!buffer.byteLength) { + showError(); + return; + } + const url = URL.createObjectURL(new Blob([buffer as Uint8Array])); + imageUrl.value = toDisposable(() => URL.revokeObjectURL(url)); + image.src = url; + }; + if (imageData && !(imageData instanceof Uint8Array)) { + image.src = `data:${imageData.mimeType};base64,${imageData.data}`; + } else if (imageData) { + render(imageData); + } else if (resource) { + void this.loadImageBytes(resource, render, showError); + } else { + showError(); + } + return store; + } + + private async loadImageBytes(resource: URI, render: (buffer: Uint8Array) => void, onError?: (message: string) => void): Promise { let content: VSBuffer; try { content = (await this.fileService.readFile(resource)).value; - } catch { - // The file may no longer exist; keep the icon fallback that is already rendered. + } catch (error) { + if (!this._store.isDisposed) { + onError?.(toErrorMessage(error)); + } return; } if (this._store.isDisposed) { diff --git a/src/vs/workbench/contrib/chat/browser/attachments/chatImageGlyphSurface.ts b/src/vs/workbench/contrib/chat/browser/attachments/chatImageGlyphSurface.ts new file mode 100644 index 00000000000000..cd97e4a7015324 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/attachments/chatImageGlyphSurface.ts @@ -0,0 +1,394 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../base/browser/dom.js'; +import { Color } from '../../../../../base/common/color.js'; +import { Disposable, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; +import { descriptionForeground, focusBorder, foreground } from '../../../../../platform/theme/common/colorRegistry.js'; +import { isHighContrast } from '../../../../../platform/theme/common/theme.js'; +import { IThemeService } from '../../../../../platform/theme/common/themeService.js'; +import { bandProfile, clamp01, easeInOutCubic, flash, getLoadingWave, hash, ILoadingWave, ImageSamples, ITextureFrame, ITextureReveal, ITextureRevealOptions, placeWave, RevealPace, smoothstep, stageAt, steerLoadingWave, twinkle, waveIntensity, wavePeriod, waveRipple } from './chatImageTextures.js'; + +const glyphs = ['0', '1', '·', ':', '=', '+', '*', '#']; +const firstCrestGlyph = 2; +const crestLevels = 6; +const binaryTones = 4; +const hotTone = 10; +const maskTone = 11; +const firstPaletteTone = 12; +const crestStart = 0.36; +const waveThreshold = 0.12; +const wakeLength = 1.8; +const message = 'happy_coding!'; +const messageBits = [...message].map(character => character.charCodeAt(0).toString(2).padStart(8, '0')).join(''); +const openLength = 900; +const passLength = 1500; + +function resizeLength(fromWidth: number, toWidth: number): number { + const change = Math.abs(toWidth - fromWidth); + return change < 1 ? 0 : Math.min(600, 360 + change * 1.25); +} + +/** The greeting repeats left-to-right across rows of the glyph grid. */ +export function getGlyphMessageBit(column: number, row: number, columns: number): number { + return messageBits[(row * columns + column) % messageBits.length] === '1' ? 1 : 0; +} + +function flashTone(packed: number): number { + return packed >> 5 < firstCrestGlyph ? binaryTones - 1 : hotTone; +} + +function swell(x: number, y: number, time: number): number { + return 0.5 + 0.28 * Math.sin(x * 0.031 - time / 1400 + 1.6 * Math.sin(y * 0.07 + time / 2300)) + 0.22 * Math.sin(x * 0.017 + y * 0.12 + time / 1900); +} + +interface IGlyphAtlas { + readonly canvas: HTMLCanvasElement; + readonly cell: number; +} + +/** Paints the loading wave and its single-pass ASCII Resolve transition into the generated image. */ +export class GlyphSurface extends Disposable { + readonly canvas: HTMLCanvasElement; + private readonly nextFrame = this._register(new MutableDisposable()); + private readonly atlases = new Map(); + private active: ITextureReveal | undefined; + private overlay: HTMLCanvasElement | undefined; + private overlayUsed = false; + private cellSize = 10; + private fontFamily = 'monospace'; + private pixelRatio = 1; + private gridX = 0; + private imagePalette: Float32Array | undefined; + private readonly stageTimes = new Float64Array(3); + + constructor( + container: HTMLElement, + @IThemeService private readonly themeService: IThemeService, + @IAccessibilityService private readonly accessibilityService: IAccessibilityService, + ) { + super(); + this.canvas = dom.append(container, dom.$('canvas.chat-image-loading-glyphs', { 'aria-hidden': 'true' })); + this._register(toDisposable(() => { + this.active?.clock.cancel(); + this.active = undefined; + this.overlay = undefined; + this.imagePalette = undefined; + this.atlases.clear(); + this.canvas.remove(); + this.canvas.width = this.canvas.height = 0; + })); + const resizeObserver = this._register(new dom.DisposableResizeObserver('ChatImageGlyphSurface', () => this.refresh())); + this._register(resizeObserver.observe(this.canvas)); + this._register(themeService.onDidColorThemeChange(() => this.refresh())); + this._register(accessibilityService.onDidChangeReducedMotion(() => this.refresh())); + } + + getRevealLength(fromWidth: number, toWidth: number): number { + return 4500 + resizeLength(fromWidth, toWidth); + } + + get showingBand(): boolean { + return !this.active && this.canvas.clientWidth > 0 && this.canvas.clientHeight > 0; + } + + reveal(options: ITextureRevealOptions): void { + if (this.active) { + return; + } + const length = this.getRevealLength(options.fromWidth, options.samples.width); + this.canvas.style.width = `${options.samples.width}px`; + this.canvas.style.height = `${options.samples.height}px`; + this.active = { options, pace: new RevealPace(length), loadingTime: Date.now() * 2, clock: this.canvas.animate([], { duration: options.duration }) }; + this.prepareReveal(this.active); + this.update(); + } + + private refresh(): void { + this.atlases.clear(); + this.measure(); + this.update(); + } + + private measure(): void { + const targetWindow = dom.getWindow(this.canvas); + const style = targetWindow.getComputedStyle(this.canvas); + this.cellSize = Math.max(6, Math.round((parseFloat(style.fontSize) || 10) / 2) * 2); + this.fontFamily = style.fontFamily || 'monospace'; + this.pixelRatio = targetWindow.devicePixelRatio || 1; + } + + private update(): void { + this.nextFrame.clear(); + const active = this.active; + if (!this.canvas.isConnected || (!active && (!this.canvas.clientWidth || !this.canvas.clientHeight))) { + return; + } + const animated = !this.accessibilityService.isMotionReduced() && !isHighContrast(this.themeService.getColorTheme().type); + if (active) { + const time = active.pace.virtualAt(clamp01(Number(active.clock.currentTime ?? 0) / active.options.duration) * active.pace.duration); + const frame = this.paintReveal(active, time); + this.canvas.style.opacity = String(frame.textureOpacity); + active.options.onFrame(frame); + } else { + this.paintLoading(animated ? Date.now() * 2 : wavePeriod * 0.55); + } + if (animated) { + this.nextFrame.value = dom.scheduleAtNextAnimationFrame(dom.getWindow(this.canvas), () => this.update()); + } + } + + private paintLoading(time: number): void { + const width = this.canvas.clientWidth; + const height = this.canvas.clientHeight; + const canvasWidth = Math.round(width * this.pixelRatio); + const canvasHeight = Math.round(height * this.pixelRatio); + if (this.canvas.width !== canvasWidth || this.canvas.height !== canvasHeight) { + this.canvas.width = canvasWidth; + this.canvas.height = canvasHeight; + } + const context = this.canvas.getContext('2d'); + const atlas = this.atlas(this.cellSize); + if (!context || !atlas) { + return; + } + context.clearRect(0, 0, canvasWidth, canvasHeight); + const size = this.cellSize; + const offsetX = this.gridOffset(width); + const columns = Math.floor(width / size); + const wave = getLoadingWave(width, time); + for (let row = 0; (row + 1) * size <= height; row++) { + for (let column = 0; column < columns; column++) { + this.drawLoadingCell(context, atlas, wave, column, row, columns, offsetX, height, time, 1); + } + } + } + + private prepareReveal(reveal: ITextureReveal): void { + const { samples, fromWidth, continuing } = reveal.options; + this.measure(); + const width = Math.max(samples.width, Math.round(fromWidth)); + this.canvas.style.width = `${width}px`; + this.canvas.width = Math.round(width * this.pixelRatio); + this.canvas.height = Math.round(samples.height * this.pixelRatio); + this.overlay = dom.$('canvas'); + this.overlay.width = this.canvas.width; + this.overlay.height = this.canvas.height; + this.imagePalette = samples.palette; + this.atlases.clear(); + samples.cells(this.cellSize); + samples.cells(this.cellSize / 2); + samples.paletteIndices(this.cellSize / 2); + this.gridX = this.gridOffset(continuing ? fromWidth : samples.width); + } + + private paintReveal(reveal: ITextureReveal, time: number): ITextureFrame { + const { samples } = reveal.options; + const context = this.canvas.getContext('2d'); + const overlay = this.overlay?.getContext('2d'); + const coarse = this.atlas(this.cellSize); + const fine = this.atlas(this.cellSize / 2); + if (!context || !overlay || !coarse || !fine) { + return { width: samples.width, height: samples.height, imageOpacity: 1, textureOpacity: 0 }; + } + context.clearRect(0, 0, this.canvas.width, this.canvas.height); + overlay.clearRect(0, 0, this.canvas.width, this.canvas.height); + this.overlayUsed = false; + const frame = this.paintResolve(reveal, time, context, overlay, coarse, fine); + if (this.overlayUsed && this.overlay) { + overlay.globalAlpha = 1; + overlay.globalCompositeOperation = 'source-in'; + overlay.drawImage(reveal.options.image, 0, 0, Math.round(samples.width * this.pixelRatio), Math.round(samples.height * this.pixelRatio)); + overlay.globalCompositeOperation = 'source-over'; + context.drawImage(this.overlay, 0, 0); + } + return frame; + } + + private paintResolve(reveal: ITextureReveal, time: number, context: CanvasRenderingContext2D, overlay: CanvasRenderingContext2D, coarse: IGlyphAtlas, fine: IGlyphAtlas): ITextureFrame { + const { samples, fromWidth } = reveal.options; + const { width, height } = samples; + const size = this.cellSize; + const from = Math.min(height, reveal.options.fromHeight); + const resize = resizeLength(fromWidth, width); + const frameWidth = fromWidth + (width - fromWidth) * (resize ? easeInOutCubic(time / resize) : 1); + const frameHeight = from + (height - from) * easeInOutCubic((time - resize) / openLength); + const opening = time - resize; + const loadingTime = reveal.loadingTime + time; + const loading = steerLoadingWave(reveal, frameWidth, time, resize + openLength, 1 / passLength); + const seed = Math.floor(reveal.loadingTime / wavePeriod) + 1; + const wave = opening >= openLength ? placeWave((opening - openLength) / passLength, width, seed, width * 0.5) : undefined; + const offsetX = this.gridX; + const imageColumns = Math.floor((width - offsetX) / size); + const columns = Math.ceil((frameWidth - offsetX) / size); + const times = this.stageTimes; + for (let row = 0; row * size < frameHeight; row++) { + const y = row * size + size / 2; + for (let column = 0; column < columns; column++) { + const x = offsetX + column * size + size / 2; + if (column >= imageColumns) { + if (frameWidth > width + 0.5) { + this.drawLoadingCell(context, coarse, loading, column, row, columns, offsetX, frameHeight, loadingTime, 1); + } + continue; + } + let glow = loading ? this.glow(loading, x, y, frameHeight, loadingTime) : 0; + const passGlow = wave ? this.glow(wave, x, y, height, loadingTime) : 0; + const drawn = wave && wave.head - x >= wave.tail * (0.3 + 0.12 * hash(column, row, 24)) && passGlow <= 0.55; + if (drawn) { + const along = x / width; + const jitter = hash(column, row, 24); + times[0] = 2300 + 700 * (0.6 * along + 0.4 * jitter); + times[1] = 2950 + 600 * (0.5 * along + 0.5 * jitter); + times[2] = 3450 + 500 * (0.5 * along + 0.5 * jitter); + const stage = stageAt(opening, times); + const flashing = stage > 0 && flash(opening - times[stage - 1], 110) > 0; + this.drawImageCell(context, overlay, coarse, fine, samples, column, row, offsetX, stage, flashing, stage >= 3 ? smoothstep(times[2], times[2] + 250, opening) : 0); + } else { + glow = Math.max(glow, passGlow); + if (glow > waveThreshold) { + this.drawWaveGlyph(context, coarse, glow, x - size / 2, y - size / 2); + } else { + this.drawLoadingCell(context, coarse, loading ?? wave, column, row, columns, offsetX, loading ? frameHeight : height, loadingTime, 1); + } + } + } + } + return { width: frameWidth, height: frameHeight, imageOpacity: smoothstep(4000, 4400, opening), textureOpacity: 1 - smoothstep(4100, 4500, opening) }; + } + + private drawImageCell(context: CanvasRenderingContext2D, overlay: CanvasRenderingContext2D, coarse: IGlyphAtlas, fine: IGlyphAtlas, samples: ImageSamples, column: number, row: number, offsetX: number, stage: number, flashing: boolean, colorAlpha: number): void { + const size = this.cellSize; + const x = offsetX + column * size; + const y = row * size; + if (stage === 0) { + const cells = samples.cells(size); + const columns = samples.columns(size); + const packed = this.imageGlyph(cells[Math.min(cells.length - 1, row * columns + column)], column, row, columns); + this.draw(context, coarse, packed >> 5, flashing ? flashTone(packed) : packed & 31, x, y); + return; + } + const half = size / 2; + const cells = samples.cells(half); + const indices = samples.paletteIndices(half); + const columns = samples.columns(half); + for (let part = 0; part < 4; part++) { + const fineColumn = column * 2 + (part & 1); + const fineRow = row * 2 + (part >> 1); + const index = Math.min(cells.length - 1, fineRow * columns + fineColumn); + const packed = this.imageGlyph(cells[index], fineColumn, fineRow, columns); + const fineX = x + (part & 1) * half; + const fineY = y + (part >> 1) * half; + if (colorAlpha < 1) { + this.draw(context, fine, packed >> 5, flashing ? flashTone(packed) : stage >= 2 ? firstPaletteTone + indices[index] : packed & 31, fineX, fineY); + } + if (colorAlpha > 0) { + overlay.globalAlpha = colorAlpha; + this.draw(overlay, fine, packed >> 5, maskTone, fineX, fineY); + this.overlayUsed = true; + } + } + } + + private drawLoadingCell(context: CanvasRenderingContext2D, atlas: IGlyphAtlas, wave: ILoadingWave | undefined, column: number, row: number, columns: number, offsetX: number, height: number, time: number, gain: number): void { + const size = this.cellSize; + const x = offsetX + column * size; + const y = row * size; + const along = x + size / 2; + const glow = wave ? this.glow(wave, along, y + size / 2, height, time) * gain : 0; + if (glow > waveThreshold) { + this.drawWaveGlyph(context, atlas, glow, x, y); + return; + } + const behind = wave ? wave.head - along : -1; + const wake = wave && behind >= 0 && behind < wave.tail * wakeLength ? (1 - behind / (wave.tail * wakeLength)) * gain : 0; + const shimmer = wave && behind >= 0 && behind < wave.tail * 1.2 ? Math.floor(time / 170 + hash(column, row, 5) * 9) & 1 : 0; + const baseLevel = 0.6 + 2 * swell(x, y, time); + const level = Math.max(baseLevel, 3.6 * wake, 3.2 * twinkle(column, row, time)) - shimmer; + const tone = Math.max(0, Math.min(binaryTones - 1, Math.floor(level))); + this.draw(context, atlas, getGlyphMessageBit(column, row, columns), tone, x, y); + } + + private drawWaveGlyph(context: CanvasRenderingContext2D, atlas: IGlyphAtlas, glow: number, x: number, y: number): void { + const level = Math.min(crestLevels - 1, Math.floor(glow * 6.2)); + this.draw(context, atlas, firstCrestGlyph + level, glow > 0.85 ? hotTone : binaryTones + level, x, y); + } + + private gridOffset(width: number): number { + return (width - Math.floor(width / this.cellSize) * this.cellSize) / 2; + } + + private glow(wave: ILoadingWave, x: number, y: number, height: number, time: number): number { + const profile = bandProfile(y, height); + return profile > 0 ? waveIntensity(wave, x) ** 0.75 * (0.7 + 0.3 * waveRipple(wave, x, y, time)) * (0.4 + 0.6 * profile) : 0; + } + + private imageGlyph(brightness: number, column: number, row: number, columns: number): number { + if (brightness >= crestStart) { + const level = Math.min(crestLevels - 1, Math.floor((brightness - crestStart) / (1 - crestStart) * crestLevels)); + return (firstCrestGlyph + level) * 32 + binaryTones + Math.min(crestLevels - 1, level + 1); + } + return getGlyphMessageBit(column, row, columns) * 32 + Math.min(binaryTones - 1, Math.floor(brightness / crestStart * binaryTones)); + } + + private draw(context: CanvasRenderingContext2D, atlas: IGlyphAtlas, glyph: number, tone: number, x: number, y: number): void { + context.drawImage(atlas.canvas, glyph * atlas.cell, tone * atlas.cell, atlas.cell, atlas.cell, Math.round(x * this.pixelRatio), Math.round(y * this.pixelRatio), atlas.cell, atlas.cell); + } + + private atlas(size: number): IGlyphAtlas | undefined { + let atlas = this.atlases.get(size); + if (!atlas) { + const cell = Math.max(1, Math.round(size * this.pixelRatio)); + const colors = this.toneColors(); + const canvas = dom.$('canvas'); + canvas.width = cell * glyphs.length; + canvas.height = cell * colors.length; + const context = canvas.getContext('2d'); + if (!context) { + return undefined; + } + context.font = `${Math.max(1, Math.round(size * this.pixelRatio))}px ${this.fontFamily}`; + context.textAlign = 'center'; + context.textBaseline = 'middle'; + colors.forEach((color, tone) => { + context.fillStyle = color; + glyphs.forEach((glyph, index) => { + context.save(); + context.beginPath(); + context.rect(index * cell, tone * cell, cell, cell); + context.clip(); + context.fillText(glyph, index * cell + cell / 2, tone * cell + cell / 2); + context.restore(); + }); + }); + atlas = { canvas, cell }; + this.atlases.set(size, atlas); + } + return atlas; + } + + private toneColors(): string[] { + const theme = this.themeService.getColorTheme(); + const text = theme.getColor(foreground) ?? Color.white; + const binary = theme.getColor(descriptionForeground) ?? text; + const accent = theme.getColor(focusBorder) ?? text; + const highContrast = isHighContrast(theme.type); + const colors = [ + ...Array.from({ length: binaryTones }, (_, index) => highContrast ? binary.toString() : binary.transparent(0.4 * ((index + 1) / binaryTones)).toString()), + ...Array.from({ length: crestLevels }, (_, index) => { + const strength = (index + 1) / crestLevels; + return highContrast ? text.toString() : accent.mix(text, 0.1 + 0.4 * strength).transparent(0.35 + 0.45 * strength).toString(); + }), + text.transparent(0.95).toString(), + '#fff', + ]; + const palette = this.imagePalette ?? new Float32Array(0); + for (let color = 0; color < palette.length; color += 3) { + colors.push(`rgb(${Math.round(palette[color])}, ${Math.round(palette[color + 1])}, ${Math.round(palette[color + 2])})`); + } + return colors; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/attachments/chatImageReveal.css b/src/vs/workbench/contrib/chat/browser/attachments/chatImageReveal.css new file mode 100644 index 00000000000000..5b925c65abc7cc --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/attachments/chatImageReveal.css @@ -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. + *--------------------------------------------------------------------------------------------*/ + +.chat-image-generation-line { + position: relative; + width: 320px; + max-width: 100%; + height: 50px; + overflow: hidden; + border-radius: var(--vscode-cornerRadius-large); +} + +.chat-image-loading-glyphs { + display: block; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 50px; + font-family: var(--monaco-monospace-font, monospace); + font-size: var(--vscode-fontSize-label3, 10px); + pointer-events: none; +} + +.chat-image-reveal { + position: relative; + width: fit-content; + max-width: 100%; + overflow: hidden; + border-radius: var(--vscode-cornerRadius-large); +} + +.chat-image-reveal.pending { + width: 320px; +} + +.chat-image-reveal.pending > img { + opacity: 0; +} + +.chat-image-reveal > img { + display: block; +} + +.chat-image-reveal.revealing { + width: var(--chat-image-reveal-frame-width); + height: var(--chat-image-reveal-frame); +} + +.chat-image-reveal.revealing > img { + opacity: var(--chat-image-reveal-image, 0); +} + +.chat-image-generation-single .chat-generated-image-result { + width: 100%; + + .chat-collapsible-io-resource-group { + width: fit-content; + flex-direction: row; + align-items: center; + } + + .chat-collapsible-io-resource-items, + .chat-attached-context { + width: auto; + } + + .chat-collapsible-io-resource-items .chat-attached-context-attachment.image-attachment { + width: 100%; + padding: 0; + border: 0; + } + + .chat-collapsible-io-resource-items .chat-attached-context-attachment.image-attachment .chat-attached-context-pill { + width: 100%; + } + + .chat-collapsible-io-resource-items .chat-attached-context-attachment.image-attachment .chat-attached-context-pill-image { + width: auto; + max-width: 100%; + } + + .chat-collapsible-io-resource-actions { + align-self: center; + } +} + +:is(.chat-image-reveal-pending, .chat-image-reveal-running) .chat-collapsible-io-resource-items .chat-attached-context-attachment.image-attachment { + overflow: visible; + border-color: transparent; +} + +:is(.chat-image-reveal-pending, .chat-image-reveal-running) .chat-collapsible-io-resource-actions { + visibility: hidden; +} diff --git a/src/vs/workbench/contrib/chat/browser/attachments/chatImageReveal.ts b/src/vs/workbench/contrib/chat/browser/attachments/chatImageReveal.ts new file mode 100644 index 00000000000000..a0d4cd0211b902 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/attachments/chatImageReveal.ts @@ -0,0 +1,111 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../base/browser/dom.js'; +import { disposableTimeout } from '../../../../../base/common/async.js'; +import { Disposable, DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ColorScheme, isHighContrast } from '../../../../../platform/theme/common/theme.js'; +import { IThemeService } from '../../../../../platform/theme/common/themeService.js'; +import { GlyphSurface } from './chatImageGlyphSurface.js'; +import { ImageSamples, RevealPace } from './chatImageTextures.js'; +import './chatImageReveal.css'; + +export interface IChatImageRevealOrigin { + readonly container: HTMLElement; +} + +const revealProperties = ['frame', 'frame-width', 'image']; + +/** Continues the loading glyph wave into the generated image once its bytes are ready. */ +export class ChatImageReveal extends Disposable { + private readonly effects = this._register(new DisposableStore()); + private readonly surface: GlyphSurface; + private started = false; + + constructor( + private readonly container: HTMLElement, + private readonly image: HTMLImageElement, + private readonly origin: IChatImageRevealOrigin | undefined, + @IAccessibilityService private readonly accessibilityService: IAccessibilityService, + @IThemeService private readonly themeService: IThemeService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + container.classList.add('chat-image-reveal', 'pending', 'chat-image-generation-line'); + origin?.container.classList.add('chat-image-reveal-pending'); + this.effects.add(toDisposable(() => { + container.classList.remove('pending', 'revealing', 'chat-image-generation-line'); + for (const property of revealProperties) { + container.style.removeProperty(`--chat-image-reveal-${property}`); + } + origin?.container.classList.remove('chat-image-reveal-pending', 'chat-image-reveal-running'); + })); + this.surface = this.effects.add(instantiationService.createInstance(GlyphSurface, container)); + this.effects.add(accessibilityService.onDidChangeReducedMotion(() => this.finishIfMotionDisabled())); + this.effects.add(themeService.onDidColorThemeChange(() => this.finishIfMotionDisabled())); + } + + reveal(loadedAt = dom.getWindow(this.container).performance.now()): void { + if (this.started || this._store.isDisposed) { + return; + } + this.started = true; + const band = this.container.getBoundingClientRect(); + this.container.classList.remove('pending'); + const performance = dom.getWindow(this.container).performance; + const { width, height } = this.image.getBoundingClientRect(); + const length = this.surface.getRevealLength(band.width, Math.round(width)); + const deadline = loadedAt + new RevealPace(length).duration; + if (!this.motionEnabled || !width || !height || performance.now() >= deadline) { + this.effects.clear(); + return; + } + if (!this.revealTexture(band, width, height, deadline - performance.now())) { + this.effects.clear(); + return; + } + this.origin?.container.classList.replace('chat-image-reveal-pending', 'chat-image-reveal-running'); + this.effects.add(disposableTimeout(() => this.effects.clear(), Math.max(0, deadline - performance.now()))); + } + + private revealTexture(band: DOMRect, width: number, height: number, duration: number): boolean { + const samples = ImageSamples.create(this.image, width, height, this.themeService.getColorTheme().type === ColorScheme.LIGHT); + if (!samples) { + return false; + } + const frameWidth = this.container.getBoundingClientRect().width; + const continuing = this.surface.showingBand; + this.container.style.setProperty('--chat-image-reveal-frame-width', `${band.width}px`); + this.container.style.setProperty('--chat-image-reveal-frame', `${band.height}px`); + this.container.classList.add('revealing'); + this.surface.reveal({ + image: this.image, + samples, + fromWidth: band.width, + fromHeight: band.height, + continuing, + duration, + onFrame: frame => { + // Canvas samples round to whole pixels; keep the measured CSS size at the final handoff. + this.container.style.setProperty('--chat-image-reveal-frame-width', `${frame.width === samples.width ? frameWidth : frame.width}px`); + this.container.style.setProperty('--chat-image-reveal-frame', `${frame.height === samples.height ? height : frame.height}px`); + this.container.style.setProperty('--chat-image-reveal-image', `${frame.imageOpacity}`); + }, + }); + return true; + } + + private get motionEnabled(): boolean { + return !this.accessibilityService.isMotionReduced() && !isHighContrast(this.themeService.getColorTheme().type); + } + + private finishIfMotionDisabled(): void { + if (this.started && !this.motionEnabled) { + this.effects.clear(); + } + } +} diff --git a/src/vs/workbench/contrib/chat/browser/attachments/chatImageTextures.ts b/src/vs/workbench/contrib/chat/browser/attachments/chatImageTextures.ts new file mode 100644 index 00000000000000..03c1dff73f37d2 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/attachments/chatImageTextures.ts @@ -0,0 +1,464 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../base/browser/dom.js'; + +/** Thresholds of a 4×4 ordered dither, in sixteenths. */ +const bayer = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5]; + +/** Returns a stable pseudo-random number in [0, 1) for a cell and a seed. */ +export function hash(x: number, y: number, seed = 0): number { + let value = Math.imul(x + 1, 0x27d4eb2d) ^ Math.imul(y + 1, 0x165667b1) ^ Math.imul(seed + 1, 0x9e3779b1); + value = Math.imul(value ^ (value >>> 15), 0x85ebca6b); + value = Math.imul(value ^ (value >>> 13), 0xc2b2ae35); + return ((value ^ (value >>> 16)) >>> 0) / 0x100000000; +} + +export function clamp01(value: number): number { + return Math.min(1, Math.max(0, value)); +} + +export function smoothstep(edge0: number, edge1: number, value: number): number { + const amount = clamp01((value - edge0) / (edge1 - edge0)); + return amount * amount * (3 - 2 * amount); +} + +export function easeInOutCubic(value: number): number { + const amount = clamp01(value); + return amount < 0.5 ? 4 * amount ** 3 : 1 - (2 - 2 * amount) ** 3 / 2; +} + +/** Fades from 1 to 0 over `length` milliseconds once `elapsed` reaches 0, for a short flash. */ +export function flash(elapsed: number, length: number): number { + return elapsed >= 0 && elapsed < length ? 1 - elapsed / length : 0; +} + +/** Maps real time to reveal time, easing from the loading band's 2x speed to normal speed. */ +export class RevealPace { + + private static readonly steps = 256; + + /** Real milliseconds at which each step of the transition is reached. */ + private readonly realTimes = new Float64Array(RevealPace.steps + 1); + + /** Real milliseconds that the whole transition takes. */ + readonly duration: number; + + constructor( + /** Virtual milliseconds that the transition takes at normal speed. */ + readonly length: number, + ) { + const startScale = 0.5; + const endScale = 1; + let real = 0; + for (let step = 1; step <= RevealPace.steps; step++) { + // The speed changes evenly on a log scale, so that halving it twice takes as long as halving it once and then again. + real += startScale * (endScale / startScale) ** smoothstep(0, 1, (step - 0.5) / RevealPace.steps) * length / RevealPace.steps; + this.realTimes[step] = real; + } + this.duration = real; + } + + /** Returns the virtual milliseconds of the transition that have passed after `real` milliseconds. */ + virtualAt(real: number): number { + const times = this.realTimes; + if (real <= 0) { + return 0; + } + if (real >= this.duration) { + return this.length; + } + let low = 0; + let high = times.length - 1; + while (high - low > 1) { + const middle = (low + high) >> 1; + if (times[middle] <= real) { + low = middle; + } else { + high = middle; + } + } + return (low + (real - times[low]) / (times[high] - times[low])) / RevealPace.steps * this.length; + } +} + +/** + * Returns the stage a cell has reached by `time`, given when it reaches each stage after the + * first, so a cell whose image has appeared is at stage 0 until `times[0]`. + */ +export function stageAt(time: number, times: ArrayLike): number { + let stage = 0; + while (stage < times.length && time >= times[stage]) { + stage++; + } + return stage; +} + +/** + * A monotone cubic from 0 to 1 whose slopes at either end come as close to the requested ones as + * monotony allows, for steering a moving wave between two speeds without it backing up. + */ +function steer(value: number, startSlope: number, endSlope: number): number { + let start = Math.max(0, startSlope); + let end = Math.max(0, endSlope); + const norm = Math.hypot(start, end); + if (norm > 3) { + start *= 3 / norm; + end *= 3 / norm; + } + const amount = clamp01(value); + const squared = amount * amount; + const cubed = squared * amount; + return (cubed - 2 * squared + amount) * start + (3 * squared - 2 * cubed) + (cubed - squared) * end; +} + +/** A comet-like wave sweeping a band from left to right, in CSS pixels. */ +export interface ILoadingWave { + /** Position of the brightest point. */ + readonly head: number; + /** Length of the fading tail behind the head. */ + readonly tail: number; + /** Varies each sweep a little, so that repeated sweeps don't look mechanical. */ + readonly seed: number; +} + +/** Virtual milliseconds per sweep of a loading wave at normal speed. */ +export const wavePeriod = 3200; + +/** Places a wave whose sweep, from entering on the left to leaving on the right, is `progress` done. */ +export function placeWave(progress: number, width: number, seed: number, tail = width * (0.4 + 0.12 * hash(seed, 7))): ILoadingWave { + return { head: progress * (width + tail * 1.3) - tail * 0.15, tail, seed }; +} + +/** Returns the loading wave at a virtual time. Every band that shares the clock shows the same wave. */ +export function getLoadingWave(width: number, time: number): ILoadingWave { + const sweeps = time / wavePeriod; + const seed = Math.floor(sweeps); + return placeWave(sweeps - seed, width, seed); +} + +/** Brightness of a wave at `x`: a soft leading edge, a bright head and a long fading tail. */ +export function waveIntensity(wave: ILoadingWave, x: number): number { + const behind = wave.head - x; + if (behind >= 0) { + return behind < wave.tail ? (1 - behind / wave.tail) ** 1.8 : 0; + } + return behind > -6 ? (1 + behind / 6) ** 2 : 0; +} + +/** Fine texture that keeps a wave from looking like a flat gradient. */ +export function waveRipple(wave: ILoadingWave, x: number, y: number, time: number): number { + return 0.5 + 0.5 * Math.sin(x * 0.0875 - y * 0.2 + time / 400 + wave.seed * 1.7); +} + +/** How strongly a wave fills a row of a band: fullest along the middle, thinner toward its edges. */ +export function bandProfile(y: number, height: number): number { + return height > 0 && y >= 0 && y < height ? Math.max(0, 1 - Math.abs(y / height - 0.5) * 1.2) : 0; +} + +/** Opacity of a twinkling background cell at a virtual time, or 0 while it rests. */ +export function twinkle(column: number, row: number, time: number): number { + if (hash(column, row, 3) >= 0.35) { + return 0; + } + const phase = (time / 2400 + hash(column, row, 4)) % 1; + return phase < 0.25 ? Math.sin(Math.PI * phase / 0.25) : 0; +} + +/** Number of colors taken from an image for its palette stages. */ +const paletteSize = 5; + +/** + * A loaded image at its displayed size, read once so that every texture stage can be derived from + * it: brightness stretched to the image's own range, mean colors of cells of any size, and a small + * palette of colors that represent it. + */ +export class ImageSamples { + + private readonly brightnessCells = new Map(); + private readonly colorCells = new Map(); + private readonly paletteCells = new Map(); + private paletteColors: Float32Array | undefined; + + private constructor( + readonly width: number, + readonly height: number, + /** RGBA of every CSS pixel, row by row. */ + readonly pixels: Uint8ClampedArray, + /** Brightness of every CSS pixel from 0 to 1, inverted for light themes. */ + readonly brightness: Float32Array, + ) { } + + /** Samples an image at a size, or returns undefined when it cannot be read. */ + static create(image: HTMLImageElement, width: number, height: number, invert: boolean): ImageSamples | undefined { + width = Math.max(1, Math.round(width)); + height = Math.max(1, Math.round(height)); + const canvas = dom.$('canvas'); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext('2d', { willReadFrequently: true }); + if (!context || !image.naturalWidth || !image.naturalHeight) { + return undefined; + } + context.imageSmoothingQuality = 'high'; + let pixels: Uint8ClampedArray; + try { + context.drawImage(image, 0, 0, width, height); + pixels = context.getImageData(0, 0, width, height).data; + } catch { + // A cross-origin image taints the canvas, so it cannot be sampled. + return undefined; + } + + const count = width * height; + const brightness = new Float32Array(count); + const histogram = new Uint32Array(256); + for (let index = 0; index < count; index++) { + const offset = index * 4; + const value = (0.2126 * pixels[offset] + 0.7152 * pixels[offset + 1] + 0.0722 * pixels[offset + 2]) * pixels[offset + 3] / (255 * 255); + brightness[index] = value; + histogram[Math.min(255, Math.floor(value * 256))]++; + } + const low = percentile(histogram, count * 0.1); + const range = Math.max(0.1, percentile(histogram, count * 0.95) - low); + for (let index = 0; index < count; index++) { + const value = clamp01((brightness[index] - low) / range); + brightness[index] = invert ? 1 - value : value; + } + return new ImageSamples(width, height, pixels, brightness); + } + + /** Number of columns in a grid of `size`-pixel cells that starts at the top-left corner. */ + columns(size: number): number { + return Math.ceil(this.width / size); + } + + /** Mean brightness of each `size`-pixel cell, row by row. */ + cells(size: number): Float32Array { + if (size <= 1) { + return this.brightness; + } + let cells = this.brightnessCells.get(size); + if (!cells) { + cells = this.average(size, 1, index => this.brightness[index]); + this.brightnessCells.set(size, cells); + } + return cells; + } + + /** Mean RGB of each `size`-pixel cell, three values per cell. */ + colors(size: number): Float32Array { + let colors = this.colorCells.get(size); + if (!colors) { + colors = this.average(size, 3, (index, channel) => this.pixels[index * 4 + channel]); + this.colorCells.set(size, colors); + } + return colors; + } + + /** A few colors that represent the image, found by k-means, three values per color. */ + get palette(): Float32Array { + if (!this.paletteColors) { + // Cluster actual pixels from a grid rather than cell means, so that colors aren't blends of their neighbors. + const stride = Math.max(1, Math.round(Math.sqrt(this.width * this.height / 4096))); + const picked: number[] = []; + for (let y = Math.floor(stride / 2); y < this.height; y += stride) { + for (let x = Math.floor(stride / 2); x < this.width; x += stride) { + const offset = (y * this.width + x) * 4; + picked.push(this.pixels[offset], this.pixels[offset + 1], this.pixels[offset + 2]); + } + } + const samples = Float32Array.from(picked); + const count = samples.length / 3; + const centers = new Float32Array(paletteSize * 3); + const distance = (sample: number, center: number) => (samples[sample * 3] - centers[center * 3]) ** 2 + (samples[sample * 3 + 1] - centers[center * 3 + 1]) ** 2 + (samples[sample * 3 + 2] - centers[center * 3 + 2]) ** 2; + + // Start from the darkest sample, then repeatedly add the sample farthest from every center. + let darkest = 0; + for (let sample = 1; sample < count; sample++) { + if (samples[sample * 3] + samples[sample * 3 + 1] + samples[sample * 3 + 2] < samples[darkest * 3] + samples[darkest * 3 + 1] + samples[darkest * 3 + 2]) { + darkest = sample; + } + } + centers.set(samples.subarray(darkest * 3, darkest * 3 + 3), 0); + for (let center = 1; center < paletteSize; center++) { + let farthest = 0; + let farthestDistance = -1; + for (let sample = 0; sample < count; sample++) { + let nearest = Infinity; + for (let existing = 0; existing < center; existing++) { + nearest = Math.min(nearest, distance(sample, existing)); + } + if (nearest > farthestDistance) { + farthest = sample; + farthestDistance = nearest; + } + } + centers.set(samples.subarray(farthest * 3, farthest * 3 + 3), center * 3); + } + + const sums = new Float64Array(paletteSize * 3); + const sizes = new Uint32Array(paletteSize); + for (let iteration = 0; iteration < 8; iteration++) { + sums.fill(0); + sizes.fill(0); + for (let sample = 0; sample < count; sample++) { + let nearest = 0; + for (let center = 1; center < paletteSize; center++) { + if (distance(sample, center) < distance(sample, nearest)) { + nearest = center; + } + } + sizes[nearest]++; + for (let channel = 0; channel < 3; channel++) { + sums[nearest * 3 + channel] += samples[sample * 3 + channel]; + } + } + for (let center = 0; center < paletteSize; center++) { + if (sizes[center]) { + for (let channel = 0; channel < 3; channel++) { + centers[center * 3 + channel] = sums[center * 3 + channel] / sizes[center]; + } + } + } + } + this.paletteColors = centers; + } + return this.paletteColors; + } + + /** + * Index into {@link palette} of each `size`-pixel cell. Each cell is ordered-dithered between its + * two nearest palette colors, so flat areas stay flat and blends between them become patterns. + */ + paletteIndices(size: number): Uint8Array { + let indices = this.paletteCells.get(size); + if (!indices) { + const colors = this.colors(size); + const palette = this.palette; + const columns = this.columns(size); + indices = new Uint8Array(colors.length / 3); + for (let cell = 0; cell < indices.length; cell++) { + const red = colors[cell * 3]; + const green = colors[cell * 3 + 1]; + const blue = colors[cell * 3 + 2]; + let nearest = 0; + let second = 0; + let nearestDistance = Infinity; + let secondDistance = Infinity; + for (let color = 0; color < palette.length / 3; color++) { + const distance = (red - palette[color * 3]) ** 2 + (green - palette[color * 3 + 1]) ** 2 + (blue - palette[color * 3 + 2]) ** 2; + if (distance < nearestDistance) { + second = nearest; + secondDistance = nearestDistance; + nearest = color; + nearestDistance = distance; + } else if (distance < secondDistance) { + second = color; + secondDistance = distance; + } + } + // How far the color sits from its nearest palette color toward the second nearest: 0 on it, about 0.5 halfway. + const towardRed = palette[second * 3] - palette[nearest * 3]; + const towardGreen = palette[second * 3 + 1] - palette[nearest * 3 + 1]; + const towardBlue = palette[second * 3 + 2] - palette[nearest * 3 + 2]; + const span = towardRed ** 2 + towardGreen ** 2 + towardBlue ** 2; + const projected = span ? clamp01(((red - palette[nearest * 3]) * towardRed + (green - palette[nearest * 3 + 1]) * towardGreen + (blue - palette[nearest * 3 + 2]) * towardBlue) / span) : 0; + // A small dead zone keeps colors that sit just off a palette color, such as a slightly noisy background, flat. + const toward = Math.max(0, projected - 0.08) / 0.92; + indices[cell] = toward * 16 > bayer[(Math.floor(cell / columns) & 3) * 4 + (cell % columns & 3)] + 0.5 ? second : nearest; + } + this.paletteCells.set(size, indices); + } + return indices; + } + + private average(size: number, channels: number, read: (index: number, channel: number) => number): Float32Array { + const columns = this.columns(size); + const cells = new Float32Array(columns * Math.ceil(this.height / size) * channels); + const counts = new Uint32Array(cells.length / channels); + for (let y = 0; y < this.height; y++) { + const rowStart = Math.floor(y / size) * columns; + for (let x = 0; x < this.width; x++) { + const cell = rowStart + Math.floor(x / size); + const index = y * this.width + x; + for (let channel = 0; channel < channels; channel++) { + cells[cell * channels + channel] += read(index, channel); + } + counts[cell]++; + } + } + for (let cell = 0; cell < counts.length; cell++) { + for (let channel = 0; channel < channels; channel++) { + cells[cell * channels + channel] /= counts[cell] || 1; + } + } + return cells; + } +} + +function percentile(histogram: Uint32Array, rank: number): number { + let seen = 0; + for (let bin = 0; bin < histogram.length; bin++) { + seen += histogram[bin]; + if (seen >= rank) { + return (bin + 0.5) / 256; + } + } + return 1; +} + +/** One painted frame of a texture reveal. */ +export interface ITextureFrame { + readonly width: number; + /** Height of the image's frame, in CSS pixels. */ + readonly height: number; + /** Opacity of the image itself, which takes over at the end. */ + readonly imageOpacity: number; + /** Opacity of the texture over the image. */ + readonly textureOpacity: number; +} + +export interface ITextureRevealOptions { + /** The loaded image, which the last stages show through the texture in its true colors. */ + readonly image: HTMLImageElement; + readonly samples: ImageSamples; + /** Width of the loading indicator that the frame grows or narrows from, in CSS pixels. */ + readonly fromWidth: number; + /** Height of the loading indicator that the frame grows from, in CSS pixels. */ + readonly fromHeight: number; + /** Whether this surface's own loading band was showing, so that the reveal carries its wave on. */ + readonly continuing: boolean; + /** Real milliseconds the reveal takes. */ + readonly duration: number; + readonly onFrame: (frame: ITextureFrame) => void; +} + +/** A texture reveal in progress. */ +export interface ITextureReveal { + readonly options: ITextureRevealOptions; + /** How the reveal's real time maps to the transition's virtual time. */ + readonly pace: RevealPace; + /** The virtual loading time when the reveal began, so that a continuing wave keeps its phase. */ + readonly loadingTime: number; + /** A keyframe-less animation whose time is the reveal's clock, so that the reveal can be paused and sought. */ + readonly clock: Animation; +} + +/** + * Returns the loading wave of a reveal, sped up or slowed down so that it leaves the band `until` + * virtual milliseconds into the reveal while moving at `endSpeed` sweeps per millisecond, the speed + * of the sweep that follows it. Returns undefined when there is no wave left to show. + */ +export function steerLoadingWave(reveal: ITextureReveal, width: number, time: number, until: number, endSpeed: number): ILoadingWave | undefined { + const sweeps = reveal.loadingTime / wavePeriod; + const seed = Math.floor(sweeps); + const start = sweeps - seed; + if (!reveal.options.continuing || time >= until) { + return undefined; + } + const remaining = 1 - start; + return placeWave(start + remaining * steer(time / until, until / wavePeriod / remaining, until * endSpeed / remaining), width, seed); +} diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index d00df7a6b38173..6f9e337e172f12 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -3,6 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import './agentSessions/agentHost/codexContinuation.contribution.js'; +import { CODEX_CONTINUATION_DEFAULT_THRESHOLD } from '../../../services/agentHost/browser/codexContinuation.js'; +import { CODEX_CONTINUATION_SETTING, CODEX_CONTINUATION_THRESHOLD_SETTING } from '../../../services/agentHost/browser/codexContinuationService.js'; import { Event } from '../../../../base/common/event.js'; import { createMarkdownCommandLink } from '../../../../base/common/htmlContent.js'; import { Disposable, DisposableMap, DisposableStore } from '../../../../base/common/lifecycle.js'; @@ -1051,6 +1054,24 @@ configurationRegistry.registerConfiguration({ tags: ['experimental'], experiment: { mode: 'startup' }, }, + [CODEX_CONTINUATION_SETTING]: { + type: 'boolean', + default: false, + included: false, + tags: ['experimental', 'advanced'], + experiment: { mode: 'auto' }, + description: nls.localize('chat.experimental.codexContinuation.enabled', "Offer to continue eligible Codex conversations with GitHub Copilot when nearing a ChatGPT usage limit."), + }, + [CODEX_CONTINUATION_THRESHOLD_SETTING]: { + type: 'number', + default: CODEX_CONTINUATION_DEFAULT_THRESHOLD, + minimum: 0, + maximum: 100, + included: false, + tags: ['experimental', 'advanced'], + experiment: { mode: 'auto' }, + description: nls.localize('chat.experimental.codexContinuation.thresholdPercent', "Minimum percentage of the five-hour or weekly ChatGPT usage limit consumed before offering to continue eligible Codex conversations with GitHub Copilot."), + }, [ChatConfiguration.ChatContextUsageEnabled]: { type: 'boolean', default: true, diff --git a/src/vs/workbench/contrib/chat/browser/chatManagement/chatManagement.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatManagement/chatManagement.contribution.ts index bb8d8507a40b4e..67cf29e4fd8417 100644 --- a/src/vs/workbench/contrib/chat/browser/chatManagement/chatManagement.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatManagement/chatManagement.contribution.ts @@ -26,6 +26,8 @@ import { CHAT_CATEGORY } from '../actions/chatActions.js'; import { ModelsManagementEditor } from './chatManagementEditor.js'; import { ModelsManagementEditorInput } from './chatManagementEditorInput.js'; import { ILanguageModelsConfigurationService } from '../../common/languageModelsConfiguration.js'; +import { ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; +import { IChatWidgetService } from '../chat.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { registerIcon } from '../../../../../platform/theme/common/iconRegistry.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; @@ -126,12 +128,18 @@ class ChatManagementActionsContribution extends Disposable implements IWorkbench f1: true, }); } - async run(accessor: ServicesAccessor, searchQuery?: string) { + async run(accessor: ServicesAccessor, searchQuery?: string, sessionType?: string) { const editorService = accessor.get(IEditorService); + // Without an explicit harness, use the one the last focused chat selects models for. + const fallbackSessionType = accessor.get(IChatWidgetService).lastFocusedWidget?.input.modelTargetSessionType + ?? accessor.get(ICustomizationHarnessService).activeHarness.get(); await ensureChatExtensionEnabled(accessor); const pane = await editorService.openEditor(new ModelsManagementEditorInput(), { pinned: true }); - if (searchQuery && pane instanceof ModelsManagementEditor) { - pane.search(searchQuery); + if (pane instanceof ModelsManagementEditor) { + pane.setSessionType(sessionType ?? fallbackSessionType); + if (searchQuery) { + pane.search(searchQuery); + } } return pane; } diff --git a/src/vs/workbench/contrib/chat/browser/chatManagement/chatManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/chatManagement/chatManagementEditor.ts index ad4dbb2cbd8729..ef0fa3b9434870 100644 --- a/src/vs/workbench/contrib/chat/browser/chatManagement/chatManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/chatManagement/chatManagementEditor.ts @@ -86,4 +86,9 @@ export class ModelsManagementEditor extends EditorPane { search(query: string): void { this.modelsWidget?.search(query); } + + /** Lists only the Copilot models of the given session type (harness). */ + setSessionType(sessionType: string | undefined): void { + this.modelsWidget?.setSessionType(sessionType); + } } diff --git a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsViewModel.ts b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsViewModel.ts index 24c23a02120c57..24d29ca2c5c3ad 100644 --- a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsViewModel.ts +++ b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsViewModel.ts @@ -11,6 +11,7 @@ import { Disposable } from '../../../../../base/common/lifecycle.js'; import { ILanguageModelsProviderGroup } from '../../common/languageModelsConfiguration.js'; import Severity from '../../../../../base/common/severity.js'; import { ILanguageModelSourcePresentation, languageModelSourcePresentationRegistry } from '../../common/languageModelSourcePresentation.js'; +import { isCopilotAgentHostSessionType } from '../../../../../platform/agentHost/common/agentHostSessionType.js'; export const MODEL_ENTRY_TEMPLATE_ID = 'model.entry.template'; export const VENDOR_ENTRY_TEMPLATE_ID = 'vendor.entry.template'; @@ -48,6 +49,16 @@ export function getManageModelsProviderLabel(model: ILanguageModel): string { return model.provider.group.name; } +/** Provider groups that are labelled "Copilot" in the list, whichever harness publishes them. */ +const COPILOT_PROVIDER_GROUPS = new Set(['copilot', 'copilotcli']); + +function isCopilotProvider(provider: ILanguageModelProvider): boolean { + return !!provider.vendor.isDefault + || COPILOT_PROVIDER_GROUPS.has(provider.group.vendor) + // A Copilot agent host's own default group, e.g. a status-only group when its models fail to load. + || (provider.group.vendor === provider.vendor.vendor && isCopilotAgentHostSessionType(provider.vendor.vendor)); +} + export interface ILanguageModelEntry { type: 'model'; id: string; @@ -131,6 +142,22 @@ export class ChatModelsViewModel extends Disposable { private searchValue: string = ''; private modelsSorted: boolean = false; + private _sessionType: string | undefined; + + /** + * Scopes the "Copilot" lists to the given chat session type (harness): only + * the harness's own Copilot models are listed, or the regular Copilot models + * if it has none. Models from other providers are unaffected. `undefined` + * lists every model. + */ + setSessionType(sessionType: string | undefined): void { + if (this._sessionType !== sessionType) { + this._sessionType = sessionType; + this.languageModelGroups = this.groupModels(this.languageModels); + this.doFilter(); + } + } + private _groupBy: ChatModelGroup = ChatModelGroup.Vendor; get groupBy(): ChatModelGroup { return this._groupBy; } set groupBy(groupBy: ChatModelGroup) { @@ -350,7 +377,7 @@ export class ChatModelsViewModel extends Disposable { private groupModels(languageModels: ILanguageModel[]): ILanguageModelEntriesGroup[] { const result: ILanguageModelEntriesGroup[] = []; if (this.groupBy === ChatModelGroup.Vendor) { - for (const model of languageModels) { + for (const model of this.getModelsInSessionScope(languageModels)) { const groupId = this.getProviderGroupId(model.provider); let group = result.find(group => group.group.id === groupId); if (!group) { @@ -362,7 +389,13 @@ export class ChatModelsViewModel extends Disposable { } group.models.push(model); } + const isInSessionScope = this.createSessionScope(); for (const statusGroup of this.languageModelGroupStatuses) { + // Agent host vendors are registered under their session type; the regular Copilot vendor belongs to no harness. + const owner = statusGroup.provider.vendor.isDefault ? undefined : statusGroup.provider.vendor.vendor; + if (!isInSessionScope(isCopilotProvider(statusGroup.provider), owner)) { + continue; + } const groupId = this.getProviderGroupId(statusGroup.provider); let group = result.find(group => group.group.id === groupId); if (!group) { @@ -404,6 +437,26 @@ export class ChatModelsViewModel extends Disposable { return result; } + private getModelsInSessionScope(languageModels: ILanguageModel[]): ILanguageModel[] { + const isInSessionScope = this.createSessionScope(); + return languageModels.filter(model => isInSessionScope(isCopilotProvider(model.provider), model.metadata.targetChatSessionType)); + } + + /** + * Returns whether an entry is listed for the selected harness. Non-Copilot entries always are. + * A Copilot entry is listed when it belongs to the selected harness, or, if that harness has + * no Copilot list of its own, when it belongs to no harness (the regular Copilot models). + */ + private createSessionScope(): (isCopilot: boolean, owner: string | undefined) => boolean { + const sessionType = this._sessionType; + if (!sessionType) { + return () => true; + } + const harnessHasOwnCopilotList = this.languageModels.some(model => isCopilotProvider(model.provider) && model.metadata.targetChatSessionType === sessionType) + || this.languageModelGroupStatuses.some(status => isCopilotProvider(status.provider) && status.provider.vendor.vendor === sessionType); + return (isCopilot, owner) => !isCopilot || (harnessHasOwnCopilotList ? owner === sessionType : owner === undefined); + } + private createLanguageModelProviderEntry(provider: ILanguageModelProvider): ILanguageModelProviderEntry { const id = this.getProviderGroupId(provider); return { @@ -511,14 +564,16 @@ export class ChatModelsViewModel extends Disposable { } getModelsForGroup(group: ILanguageModelProviderEntry | ILanguageModelGroupEntry): ILanguageModel[] { + // Only the models listed for the selected harness: groups with the same id can come from several harnesses. + const languageModels = this.getModelsInSessionScope(this.languageModels); if (isLanguageModelProviderEntry(group)) { - return this.languageModels.filter(m => + return languageModels.filter(m => this.getProviderGroupId(m.provider) === group.id ); } // return all models ungrouped - return this.languageModels; + return languageModels; } toggleModelHidden(entry: ILanguageModelEntry): void { diff --git a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts index 4ce4691ae03ee5..35182b8f29cbc4 100644 --- a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts @@ -1713,6 +1713,14 @@ export class ChatModelsWidget extends Disposable { this.searchWidget.setValue(''); } + /** + * Lists only the Copilot models of the given session type (harness). + * Pass `undefined` to list every model. + */ + public setSessionType(sessionType: string | undefined): void { + this.viewModel.setSessionType(sessionType); + } + public render(): void { if (this.viewModel.shouldRefilter()) { this.viewModel.filter(this.searchWidget.getValue()); diff --git a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts index df65a276f501ce..36d7211b43ec0c 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts @@ -31,6 +31,7 @@ import { ConfigureToolsAction } from './actions/chatToolActions.js'; import { IAgentSessionsService } from './agentSessions/agentSessionsService.js'; import { IAgentHostSessionWorkingDirectoryResolver } from './agentSessions/agentHost/agentHostSessionWorkingDirectoryResolver.js'; import { toAgentHostBackendSessionUri } from './agentSessions/agentHost/agentHostSessionUri.js'; +import { IAgentHostConnectionsService } from '../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { IAgentHostUntitledProvisionalSessionService } from './agentSessions/agentHost/agentHostUntitledProvisionalSessionService.js'; import { CONFIGURE_INSTRUCTIONS_ACTION_ID } from './promptSyntax/attachInstructionsAction.js'; import { showConfigureHooksQuickPick } from './promptSyntax/hookActions.js'; @@ -64,6 +65,7 @@ export class ChatSlashCommandsContribution extends Disposable { @IWorkspaceContextService workspaceContextService: IWorkspaceContextService, @IChatPetService chatPetService: IChatPetService, @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, + @IAgentHostConnectionsService connectionsService: IAgentHostConnectionsService, ) { super(); @@ -256,7 +258,7 @@ export class ChatSlashCommandsContribution extends Disposable { return state && !(state instanceof Error) ? state.config?.values : undefined; }; const setPermissionLevelForSession = async (sessionResource: URI, level: ChatPermissionLevel) => { - const backendSession = toAgentHostBackendSessionUri(sessionResource); + const backendSession = toAgentHostBackendSessionUri(sessionResource, connectionsService); if (backendSession) { const permittedLevel = configurationService.inspect(ChatConfiguration.GlobalAutoApprove).policyValue === false ? ChatPermissionLevel.Default @@ -264,7 +266,7 @@ export class ChatSlashCommandsContribution extends Disposable { const partial = { [SessionConfigKey.AutoApprove]: permittedLevel }; const workingDirectory = getAgentHostWorkingDirectory(sessionResource); if (isUntitledChatSession(sessionResource)) { - await agentHostProvisionalService.applyConfigChange(sessionResource, backendSession.scheme, workingDirectory, partial); + await agentHostProvisionalService.applyConfigChange(sessionResource, sessionResource.scheme.substring('agent-host-'.length), workingDirectory, partial); return; } diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxAgentHostService.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxAgentHostService.ts index f8b3726e79afa4..ff3fd24af4a67f 100644 --- a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxAgentHostService.ts +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxAgentHostService.ts @@ -11,16 +11,17 @@ import { isWeb } from '../../../../../base/common/platform.js'; import { disposableTimeout, raceCancellationError, timeout } from '../../../../../base/common/async.js'; import { StopWatch } from '../../../../../base/common/stopwatch.js'; import { localize } from '../../../../../nls.js'; -import { IProtocolTransport } from '../../../../../platform/agentHost/common/state/sessionTransport.js'; +import { AgentHostTransportFailureReason, NonReconnectableTransportError, IProtocolTransport } from '../../../../../platform/agentHost/common/state/sessionTransport.js'; import { AgentHostProtocolClient } from '../../../../../platform/agentHost/browser/agentHostProtocolClient.js'; import { agentsWindowAgentHostClientInfo, editorWindowAgentHostClientInfo } from '../../../../../platform/agentHost/common/agentHostClientInfo.js'; import { traceConnectionOperation, type IConnectionDiagnosticEvent } from '../../../../../platform/agentHost/common/connectionDiagnostics.js'; import { WebPubSubRelayTransport } from '../../../../../platform/agentHost/browser/webPubSubRelayTransport.js'; import { AhpJsonlLogger } from '../../../../../platform/agentHost/common/ahpJsonlLogger.js'; -import { GITHUB_COPILOT_PROTECTED_RESOURCE, AgentHostAhpJsonlLoggingSettingId } from '../../../../../platform/agentHost/common/agentService.js'; +import { GITHUB_COPILOT_PROTECTED_RESOURCE, AgentHostAhpJsonlLoggingSettingId, IAgentHostService } from '../../../../../platform/agentHost/common/agentService.js'; import { buildWpsUrl, cloudSandboxAddress, + cloudSandboxEnvironmentId, CloudSandboxEnabledSettingId, ICloudSandboxAgentHostService, ICloudSandboxConnectOptions, @@ -150,6 +151,7 @@ class CloudSandboxConnectionFactory extends Disposable implements IRemoteAgentHo address, environmentId: options.environmentId, sessionId: options.sessionId, + ...(options.environmentKind ? { environmentKind: options.environmentKind } : {}), }, }; this._stagedConnections.set(address, { @@ -247,10 +249,25 @@ class CloudSandboxConnectionFactory extends Disposable implements IRemoteAgentHo clientInfo: this._environmentService.isSessionsWindow ? agentsWindowAgentHostClientInfo : editorWindowAgentHostClientInfo, reconnectPolicy: SANDBOX_RECONNECT_POLICY, prepareReconnect: () => traceConnectionOperation(diagnosticObserver, 'credentials', async () => { - if (staged.reconnectRequiresRefresh) { - await refresher.refreshConnectionCredentials(); - } else { - await refresher.ensureUnexpiredCredentials(); + try { + if (staged.reconnectRequiresRefresh || staged.options.environmentKind === 'user-local') { + await refresher.refreshConnectionCredentials(); + } else { + await refresher.ensureUnexpiredCredentials(); + } + } catch (error) { + if (this._stagedConnections.get(address) !== staged || store.isDisposed) { + throw new CancellationError(); + } + if (staged.refreshState.permanentFailure) { + this.unstageConfiguration(address); + throw new NonReconnectableTransportError('Mission Control refused connection recovery; reconnect after resolving environment availability or account authorization.', + staged.refreshState.permanentFailureStatus === 404 ? AgentHostTransportFailureReason.HostNotRunning : AgentHostTransportFailureReason.Unknown); + } + throw error; + } + if (this._stagedConnections.get(address) !== staged || store.isDisposed) { + throw new CancellationError(); } staged.reconnectRequiresRefresh = true; }), @@ -290,6 +307,10 @@ class CloudSandboxConnectionFactory extends Disposable implements IRemoteAgentHo private _updateEntries(): void { this._entries.set([...this._stagedConnections.values()].map(connection => connection.entry), undefined); } + + getUserLocalEnvironmentIds(account: string | undefined): readonly string[] { + return [...this._stagedConnections.values()].filter(connection => connection.options.environmentKind === 'user-local' && connection.options.accountKey !== account).map(connection => connection.options.environmentId); + } } /** Renderer-side coordinator for Copilot cloud sandbox connections. */ @@ -297,6 +318,7 @@ export class CloudSandboxAgentHostService extends Disposable implements ICloudSa declare readonly _serviceBrand: undefined; private readonly _connectionFactory: CloudSandboxConnectionFactory; + private _accountGeneration = 0; /** Overridable so tests can exercise the re-mint loop without waiting on real delays. */ protected readonly sealedTokenRetryDelayMs: number = SEALED_TOKEN_RETRY_DELAY_MS; @@ -307,10 +329,17 @@ export class CloudSandboxAgentHostService extends Disposable implements ICloudSa @IConfigurationService private readonly _configurationService: IConfigurationService, @IInstantiationService private readonly _instantiationService: IInstantiationService, @ILogService private readonly _logService: ILogService, + @IAgentHostService private readonly _localAgentHostService: IAgentHostService, ) { super(); this._connectionFactory = this._register(this._instantiationService.createInstance(CloudSandboxConnectionFactory)); this._register(this._remoteAgentHostService.registerConnectionFactory(this._connectionFactory)); + this._register(this._apiService.onDidChangeAccount(account => { + this._accountGeneration++; + for (const id of this._connectionFactory.getUserLocalEnvironmentIds(account)) { + void this.disconnect(id).catch(error => this._logService.error('Failed to withdraw user-local MC connection', error)); + } + })); } getSealedGitHubToken(environmentId: string): string | undefined { @@ -321,19 +350,35 @@ export class CloudSandboxAgentHostService extends Disposable implements ICloudSa return this._connectionFactory.refreshSealedGitHubToken(environmentId); } - async disconnect(address: string): Promise { + async disconnect(addressOrEnvironmentId: string): Promise { + const address = cloudSandboxEnvironmentId(addressOrEnvironmentId) === undefined ? cloudSandboxAddress(addressOrEnvironmentId) : addressOrEnvironmentId; this._connectionFactory.endConnectionTelemetry(address, true); this._connectionFactory.unstageConfiguration(address); await this._remoteAgentHostService.removeRemoteAgentHost(address); } async connect(options: ICloudSandboxConnectOptions, token: CancellationToken): Promise { - if (!this._configurationService.getValue(CloudSandboxEnabledSettingId)) { + if (options.environmentKind !== 'user-local' && !this._configurationService.getValue(CloudSandboxEnabledSettingId)) { throw new Error('Copilot cloud sandbox connections are not enabled.'); } if (!this._configurationService.getValue(RemoteAgentHostsEnabledSettingId)) { throw new Error('Remote agent host connections are not enabled.'); } + let userLocalAccount: string | undefined; + const accountGeneration = this._accountGeneration; + if (options.environmentKind === 'user-local') { + if (await this._localAgentHostService.getMissionControlEnvironmentId?.() === options.environmentId) { + throw new Error('Use local IPC for this window\'s own Agent Host, not its Mission Control relay.'); + } + const account = await this._apiService.getAccountKey(); + if (!account) { + throw new Error('User-local Mission Control connections require a signed-in account.'); + } + userLocalAccount = account; + for (const id of this._connectionFactory.getUserLocalEnvironmentIds(account)) { + await this.disconnect(id); + } + } const address = cloudSandboxAddress(options.environmentId); @@ -372,6 +417,12 @@ export class CloudSandboxAgentHostService extends Disposable implements ICloudSa 'credentials', () => raceCancellationError(this._mintWithWaking(options, source.token, requestTelemetry), source.token), ); + if (userLocalAccount !== undefined) { + const verifiedAccount = await this._apiService.getAccountKey(); + if (accountGeneration !== this._accountGeneration || verifiedAccount !== userLocalAccount) { + throw new CancellationError(); + } + } // A token only means Mission Control believes the environment is online — a sandbox deleted // minutes ago still has a fresh heartbeat, so one is minted for a host that is already gone. @@ -380,7 +431,7 @@ export class CloudSandboxAgentHostService extends Disposable implements ICloudSa clientId = clientToken.client_id; this._logService.info(`${LOG_PREFIX} Credentials ready: environmentId=${options.environmentId} sessionId=${options.sessionId ?? 'none'} clientId=${clientId} durationMs=${watch.elapsed()}`); requestTelemetry?.setConnectStage('connection'); - const result = await this._establish(options, address, clientToken, source.token); + const result = await this._establish(userLocalAccount === undefined ? options : { ...options, accountKey: userLocalAccount }, address, clientToken, source.token); telemetry?.completeConnect(token.isCancellationRequested ? 'cancelled' : 'success'); return result; } catch (error) { diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxApiService.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxApiService.ts index b211a193a5474d..36ff8af42415b7 100644 --- a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxApiService.ts +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxApiService.ts @@ -8,6 +8,7 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { toErrorMessage } from '../../../../../base/common/errorMessage.js'; import { CancellationError, isCancellationError } from '../../../../../base/common/errors.js'; import { Emitter } from '../../../../../base/common/event.js'; +import { isObject } from '../../../../../base/common/types.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; import { localize } from '../../../../../nls.js'; import { StopWatch } from '../../../../../base/common/stopwatch.js'; @@ -25,6 +26,7 @@ import { ICloudSandboxDiscoveredSession, ICloudSandboxDiscoveryResult, ICloudSandboxEnvironment, + IMissionControlEnvironment, ICloudSandboxModelCatalog, } from '../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; import { IReplayedTaskHistory, parseTaskEventsResponse, replayTaskAhpEvents, TaskEventReplayError } from '../../../../../platform/agentHost/common/taskEventReplay.js'; @@ -212,6 +214,7 @@ export class CloudSandboxApiService extends Disposable implements ICloudSandboxA private readonly _discoveredTasks = new Map(); private _discoverySince: string | undefined; private _discoveryGeneration = 0; + private _environments: { readonly generation: number; readonly fetchedAt: number; readonly values: readonly IMissionControlEnvironment[] } | undefined; private readonly _onDidChangeAccount = this._register(new Emitter()); readonly onDidChangeAccount = this._onDidChangeAccount.event; @@ -302,6 +305,49 @@ export class CloudSandboxApiService extends Disposable implements ICloudSandboxA return environment; } + getCachedEnvironments(): readonly IMissionControlEnvironment[] | undefined { + return this._environments?.generation === this._discoveryGeneration && Date.now() - this._environments.fetchedAt < 60_000 + ? this._environments.values : undefined; + } + + async listEnvironments(token: CancellationToken, options?: { readonly refresh?: boolean }): Promise { + const generation = this._discoveryGeneration; + const cached = this.getCachedEnvironments(); + if (!options?.refresh && cached) { + return cached; + } + const context = await this._request(`${GITHUB_DOT_COM_COPILOT_API_BASE_URI}/agents/environments`, 'mc.environmentClient.list', 'listEnvironments', { + 'Copilot-Integration-Id': COPILOT_INTEGRATION_ID, + }, token); + if (!isSuccess(context)) { + await this._throwForStatus('list environments', context); + } + const response = await this._readJson(context); + if (!Array.isArray(response)) { + throw new Error('Mission Control returned invalid environment discovery metadata'); + } + const values: IMissionControlEnvironment[] = []; + for (const value of response as readonly unknown[]) { + if (!isObject(value)) { + this._logService.warn('Ignoring malformed Mission Control environment metadata'); + continue; + } + const environment = value as Partial; + if (typeof environment.id !== 'string' || !/^[A-Za-z0-9_-]+$/.test(environment.id) + || typeof environment.name !== 'string' || !environment.name.trim() || typeof environment.kind !== 'string' + || typeof environment.status !== 'string' || !environment.status) { + this._logService.warn('Ignoring incomplete Mission Control environment metadata'); + continue; + } + values.push({ id: environment.id, name: environment.name, kind: environment.kind, status: environment.status }); + } + if (generation !== this._discoveryGeneration) { + throw new CancellationError(); + } + this._environments = { generation, fetchedAt: Date.now(), values }; + return values; + } + /** Incremental scans preserve absent tasks; full scans also find tasks without environment-kind metadata. */ async listSessions(token: CancellationToken, options?: { readonly incremental?: boolean }): Promise { const generation = this._discoveryGeneration; diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxConnectionCustomization.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxConnectionCustomization.ts index bb371d634c8c38..6ea567b81f95c8 100644 --- a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxConnectionCustomization.ts +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxConnectionCustomization.ts @@ -6,6 +6,7 @@ import { Event } from '../../../../../base/common/event.js'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { AgentHostProtocolClient } from '../../../../../platform/agentHost/browser/agentHostProtocolClient.js'; +import { GITHUB_COPILOT_PROTECTED_RESOURCE } from '../../../../../platform/agentHost/common/agentService.js'; import { AuthRequiredReason } from '../../../../../platform/agentHost/common/state/sessionActions.js'; import { CLOUD_SANDBOX_ADDRESS_PREFIX, @@ -48,6 +49,8 @@ function isGitHubResource(resource: string): boolean { export function createCloudSandboxConnectionCustomization( address: string, sandboxService: ICloudSandboxAgentHostService, + userLocal = false, + sealUserLocal?: (request: IAgentHostAuthenticateRequest) => Promise, ): IRemoteAgentHostConnectionCustomization | undefined { const environmentId = cloudSandboxEnvironmentId(address); if (environmentId === undefined) { @@ -71,12 +74,18 @@ export function createCloudSandboxConnectionCustomization( if (reason !== AuthRequiredReason.Expired && isCloudSandboxSealedToken(request.token)) { return request; } + if (userLocal && new URL(request.resource).origin !== new URL(GITHUB_COPILOT_PROTECTED_RESOURCE.resource).origin) { + if (!sealUserLocal) { + throw new Error('User-local Mission Control sealing is unavailable.'); + } + return sealUserLocal(request); + } return resolveAuthentication(request.resource, request.scopes, reason === AuthRequiredReason.Expired); }, - renewAuthentication: resource => resolveAuthentication(resource.resource, resource.scopes_supported, true), + renewAuthentication: userLocal ? undefined : resource => resolveAuthentication(resource.resource, resource.scopes_supported, true), backendSessionScheme: (provider: string): string | undefined => - provider === CLOUD_SANDBOX_AGENT_PROVIDER ? CLOUD_SANDBOX_SESSION_SCHEME : undefined, - createSessionPreparation: (connection, owner) => { + provider === CLOUD_SANDBOX_AGENT_PROVIDER || (userLocal && provider === 'copilotcli') ? CLOUD_SANDBOX_SESSION_SCHEME : undefined, + createSessionPreparation: userLocal ? undefined : (connection, owner) => { if (!(connection instanceof AgentHostProtocolClient)) { throw new Error('Cloud sandbox session preparation requires a protocol client.'); } diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxCredentialRefresh.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxCredentialRefresh.ts index f5258439fe4fa3..af6aa770cd25a3 100644 --- a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxCredentialRefresh.ts +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxCredentialRefresh.ts @@ -24,6 +24,26 @@ import { ICloudSandboxTelemetryService, type CloudSandboxRefreshStopReason } fro const LOG_PREFIX = '[CloudSandboxAgentHost]'; +/** A broker may omit unchanged sealing material; a replacement key needs matching new ciphertext. */ +export function mergeCloudSandboxConnectionToken(previous: ICloudSandboxClientToken, refreshed: ICloudSandboxClientToken): ICloudSandboxClientToken | undefined { + const reusesSealedToken = !refreshed.encrypted_github_token; + const sealedToken = refreshed.encrypted_github_token || previous.encrypted_github_token; + const hostKey = refreshed.host_encryption_key; + if (hostKey) { + const matchesKey = typeof sealedToken === 'string' + && typeof hostKey.key_id === 'string' && hostKey.key_id.length > 0 + && sealedToken.startsWith(`${CLOUD_SANDBOX_SEALED_TOKEN_PREFIX}${hostKey.key_id}.`); + const reusedKeyChanged = reusesSealedToken && previous.host_encryption_key !== undefined + && !equals(previous.host_encryption_key, hostKey); + if (!matchesKey || reusedKeyChanged) { + return undefined; + } + } + return reusesSealedToken + ? { ...refreshed, encrypted_github_token: sealedToken, host_encryption_key: hostKey ?? previous.host_encryption_key } + : refreshed; +} + /** Refresh the Web PubSub credentials this long before the access token's `expires_at`. */ const CREDENTIAL_REFRESH_LEAD_MS = 60_000; @@ -85,6 +105,8 @@ export class CloudSandboxCredentialRefreshState { lastRefreshAt: number | undefined; nextRefreshAt: number | undefined; stopped = false; + permanentFailure = false; + permanentFailureStatus: number | undefined; } /** @@ -240,6 +262,7 @@ export class CloudSandboxCredentialRefresher extends Disposable { private async _doRefresh(): Promise { let result: CloudSandboxConnectResult; + const previousToken = this._creds.token; try { result = await this._apiService.reconnect(this._request, this._clientId, this._cts.token); } catch (err) { @@ -252,6 +275,8 @@ export class CloudSandboxCredentialRefresher extends Disposable { // A rejected request (deleted environment, revoked token) fails identically however // often it is repeated, so retrying only adds load without any prospect of recovery. if (!isRetryableCloudSandboxError(err)) { + this._refreshState.permanentFailure = true; + this._refreshState.permanentFailureStatus = err instanceof CloudSandboxRequestError ? err.statusCode : undefined; this._stop('permanentError', toErrorMessage(err), err); return false; } @@ -267,6 +292,11 @@ export class CloudSandboxCredentialRefresher extends Disposable { if (this._cts.token.isCancellationRequested) { return false; } + if (this._creds.token !== previousToken) { + this._logService.trace(`${LOG_PREFIX} Ignoring a superseded credential refresh for ${this._address}`); + this._arm(credentialRefreshDelayMs(this._creds.token.expires_at) ?? CREDENTIAL_REFRESH_FALLBACK_MS); + return true; + } if (result.kind === 'waking') { // `/reconnect` refreshes an already-connected client, so a waking environment here is the @@ -275,26 +305,13 @@ export class CloudSandboxCredentialRefresher extends Disposable { return false; } - const previousToken = this._creds.token; - const refreshedToken = result.token; - const reusesSealedToken = !refreshedToken.encrypted_github_token; - const sealedToken = refreshedToken.encrypted_github_token || previousToken.encrypted_github_token; - const hostKey = refreshedToken.host_encryption_key; - if (hostKey) { - const sealedTokenMatchesKey = typeof sealedToken === 'string' - && typeof hostKey.key_id === 'string' && hostKey.key_id.length > 0 - && sealedToken.startsWith(`${CLOUD_SANDBOX_SEALED_TOKEN_PREFIX}${hostKey.key_id}.`); - const reusedKeyChanged = reusesSealedToken && previousToken.host_encryption_key !== undefined - && !equals(previousToken.host_encryption_key, hostKey); - if (!sealedTokenMatchesKey || reusedKeyChanged) { - this._logService.warn(`${LOG_PREFIX} Credential refresh for ${this._address} returned inconsistent host credentials; retrying`); - this._armUnhealthy(CREDENTIAL_REFRESH_RETRY_MS, 'unusableToken', 'refreshed host keys did not match the sealed credentials'); - return false; - } + const refreshedToken = mergeCloudSandboxConnectionToken(previousToken, result.token); + if (!refreshedToken) { + this._logService.warn(`${LOG_PREFIX} Credential refresh for ${this._address} returned inconsistent host credentials; retrying`); + this._armUnhealthy(CREDENTIAL_REFRESH_RETRY_MS, 'unusableToken', 'refreshed host keys did not match the sealed credentials'); + return false; } - this._creds.token = reusesSealedToken - ? { ...refreshedToken, encrypted_github_token: sealedToken, host_encryption_key: hostKey ?? previousToken.host_encryption_key } - : refreshedToken; + this._creds.token = refreshedToken; this._logService.trace(`${LOG_PREFIX} Refreshed Web PubSub credentials for ${this._address}`); const delayMs = credentialRefreshDelayMs(result.token.expires_at); diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxReadOnlySessionHandler.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxReadOnlySessionHandler.ts index ec1bf43cd4c5e6..8087c456c363d9 100644 --- a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxReadOnlySessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxReadOnlySessionHandler.ts @@ -122,7 +122,7 @@ export class CloudSandboxReadOnlySessionHandler extends Disposable implements IC // interleave unrelated conversations rather than show more history. const chat = session.chats.get(session.defaultChat) ?? [...session.chats.values()][0]; const history: IChatSessionHistoryItem[] = chat - ? turnsToHistory(URI.parse(session.session), chat.turns, this._config.agentId, this._config.connectionAuthority, undefined, undefined, undefined, undefined, this._config.agentId) + ? turnsToHistory(URI.parse(session.session), chat.turns, this._config.agentId, this._config.connectionAuthority) : []; // The compute most likely died mid-turn, so the unfinished exchange is exactly the one the @@ -136,7 +136,7 @@ export class CloudSandboxReadOnlySessionHandler extends Disposable implements IC prompt: active.message.text, participant: this._config.agentId, variableData: messageToVariableData(active.message, this._config.connectionAuthority), - origin: messageToRequestOrigin(URI.parse(session.session), active.message, this._config.agentId, this._config.agentId), + origin: messageToRequestOrigin(URI.parse(session.session), active.message, this._config.agentId, this._config.connectionAuthority), }); history.push({ type: 'response', diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxSessionContribution.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxSessionContribution.ts index 073ac30e68aa3f..1de07b197fcbb2 100644 --- a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxSessionContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxSessionContribution.ts @@ -15,6 +15,7 @@ import { localize } from '../../../../../nls.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; import { CLOUD_SANDBOX_AGENT_PROVIDER, + CLOUD_SANDBOX_SESSION_SCHEME, CloudSandboxEnabledSettingId, CloudSandboxAuthenticationRequiredError, cloudSandboxAddress, @@ -26,11 +27,11 @@ import { type ICloudSandboxDiscoveredSession, } from '../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; import { AgentSession, type IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agent.js'; -import { IAgentConnection } from '../../../../../platform/agentHost/common/agentService.js'; +import { IAgentConnection, IAgentHostService } from '../../../../../platform/agentHost/common/agentService.js'; import { IReplayedTaskHistory } from '../../../../../platform/agentHost/common/taskEventReplay.js'; import { AGENT_HOST_SCHEME, agentHostAuthority } from '../../../../../platform/agentHost/common/agentHostUri.js'; import { findRemoteAgentHostSessionTypeAuthority, remoteAgentHostSessionTypeId } from '../../../../../platform/agentHost/common/agentHostSessionType.js'; -import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostsEnabledSettingId } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { getEntryAddress, IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; @@ -43,6 +44,7 @@ import { IHostService } from '../../../../services/host/browser/host.js'; import { CloudSandboxReadOnlySessionHandler } from './cloudSandboxReadOnlySessionHandler.js'; import { IRemoteAgentHostConnectionCustomizationService } from './remoteAgentHostConnectionCustomization.js'; import { createCloudSandboxConnectionCustomization, isCloudSandboxConnectionAddress } from './cloudSandboxConnectionCustomization.js'; +import { sealMissionControlMcpCredential } from './missionControlCredentialSealing.js'; import { withSessionInitiator } from '../../../../../platform/agentHost/common/meta/agentSessionInitiatorMeta.js'; const LOG_PREFIX = '[CloudSandboxAgentHost]'; @@ -155,6 +157,7 @@ export abstract class CloudSandboxSessionContribution createCloudSandboxConnectionCustomization(address, this._cloudSandboxService)!, + address => { + const connection = this._remoteAgentHostService.configuredEntries.find(entry => getEntryAddress(entry) === address)?.connection; + const userLocal = connection?.type === RemoteAgentHostEntryType.CloudSandbox && connection.environmentKind === 'user-local'; + return createCloudSandboxConnectionCustomization(address, this._cloudSandboxService, userLocal, async request => { + if (!userLocal) { + throw new Error('User-local MC sealing requires a user-local connection.'); + } + const environment = await this._apiService.getEnvironment(connection.environmentId, CancellationToken.None); + const client = this._remoteAgentHostService.getConnection(address); + const root = client?.rootState.value; + if (environment.id !== connection.environmentId || !client || !root || root instanceof Error) { + throw new Error('Mission Control environment or host is unavailable for sealing.'); + } + const initialization = client.initializeResult.get(); + const result = await sealMissionControlMcpCredential(request, environment.encryption_keys, root, initialization?._meta, sealingRequest => { + if (!this._localAgentHostService.sealMissionControlCredential) { + throw new Error('Trusted local credential sealing is unavailable.'); + } + return this._localAgentHostService.sealMissionControlCredential(sealingRequest); + }); + if (this._remoteAgentHostService.getConnection(address) !== client || client.initializeResult.get() !== initialization) { + throw new CancellationError(); + } + return result; + })!; + }, )); // Keep providers wired to their live connections and their status fresh. @@ -336,7 +364,7 @@ export abstract class CloudSandboxSessionContribution this._requireConnection().dispatch(channel, action), }, { filterToWorkspace: false, + connectionAuthority: authority, sessionSchemeAlias: { ui: CLOUD_SANDBOX_AGENT_PROVIDER, backend: CLOUD_SANDBOX_SESSION_SCHEME }, })); this._controller = this._register(instantiationService.createInstance(AgentHostSessionListController, diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxTelemetry.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxTelemetry.ts index 9026b2637a7bc3..9287278e79afb5 100644 --- a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxTelemetry.ts +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/cloudSandboxTelemetry.ts @@ -13,7 +13,7 @@ import { createDecorator } from '../../../../../platform/instantiation/common/in import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; /** The Mission Control call being reported. A closed set, so it is safe to send verbatim. */ -export type CloudSandboxRequestAction = 'connect' | 'reconnect' | 'getEnvironment' | 'listTasks' | 'getTask' | 'createTask' | 'deleteTask' | 'renameTask' | 'archiveTask' | 'unarchiveTask' | 'getTaskEvents' | 'getRepository' | 'listModels'; +export type CloudSandboxRequestAction = 'connect' | 'reconnect' | 'getEnvironment' | 'listEnvironments' | 'listTasks' | 'getTask' | 'createTask' | 'deleteTask' | 'renameTask' | 'archiveTask' | 'unarchiveTask' | 'getTaskEvents' | 'getRepository' | 'listModels'; /** * How a Mission Control request ended, bucketed so a count is meaningful without carrying the diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/editorCloudSandboxContribution.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/editorCloudSandboxContribution.ts index 90fa8e41482e03..6cd58129ed55b3 100644 --- a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/editorCloudSandboxContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/editorCloudSandboxContribution.ts @@ -12,6 +12,7 @@ import { extUriBiasedIgnorePathCase } from '../../../../../base/common/resources import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { IAgentHostService } from '../../../../../platform/agentHost/common/agentService.js'; import { agentHostAuthority } from '../../../../../platform/agentHost/common/agentHostUri.js'; import { ChangesetKind } from '../../../../../platform/agentHost/common/changesetUri.js'; import { CLOUD_SANDBOX_AGENT_PROVIDER, CLOUD_SANDBOX_SESSION_SCHEME, cloudSandboxAddress, ICloudSandboxAgentHostService, ICloudSandboxApiService } from '../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; @@ -63,8 +64,9 @@ export class EditorCloudSandboxSessionContribution extends CloudSandboxSessionCo @IGitService private readonly _gitService: IGitService, @ISCMService scmService: ISCMService, @IWorkspaceTrustManagementService workspaceTrustManagementService: IWorkspaceTrustManagementService, + @IAgentHostService localAgentHostService: IAgentHostService, ) { - super(cloudSandboxService, apiService, remoteAgentHostService, connectionCustomizations, configurationService, instantiationService, _editorChatSessionsService, _editorLogService, chatEntitlementService, hostService, storageService, workspaceTrustManagementService); + super(cloudSandboxService, apiService, remoteAgentHostService, connectionCustomizations, configurationService, instantiationService, _editorChatSessionsService, _editorLogService, chatEntitlementService, hostService, storageService, workspaceTrustManagementService, localAgentHostService); const enabled = observableFromEvent(this, Event.any(configurationService.onDidChangeConfiguration, chatEntitlementService.onDidChangeSentiment), () => this._isEnabled()); const workspaceChanged = observableSignalFromEvent(this, workspaceContextService.onDidChangeWorkspaceFolders); const repositoriesChanged = observableSignalFromEvent(this, Event.any(scmService.onDidAddRepository, scmService.onDidRemoveRepository)); diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/missionControlCredentialSealing.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/missionControlCredentialSealing.ts new file mode 100644 index 00000000000000..8f8e80e33ed337 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/missionControlCredentialSealing.ts @@ -0,0 +1,46 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { isObject } from '../../../../../base/common/types.js'; +import type { IHostEncryptionKey } from '../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; +import type { IMissionControlCredentialSealingRequest } from '../../../../../platform/agentHost/common/agentService.js'; +import type { RootState } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import type { IAgentHostAuthenticateRequest } from '../agentSessions/agentHost/agentHostAuth.js'; + +/** Pins MCP sealing to the authenticated environment descriptor and this handshake's advertised key. */ +export async function sealMissionControlMcpCredential( + request: IAgentHostAuthenticateRequest, + trustedKeys: readonly IHostEncryptionKey[] | undefined, + root: RootState, + handshakeMeta: Record | undefined, + seal: (request: IMissionControlCredentialSealingRequest) => Promise, +): Promise { + const advertised = root._meta?.['copilot.encryptionKeys']; + if (!Array.isArray(trustedKeys) || !Array.isArray(advertised)) { + throw new Error('Mission Control has no authenticated MCP recipient key.'); + } + const key = trustedKeys.find(key => key.use === 'mcp-auth-token' && key.algorithm === 'x25519-sealedbox' + && advertised.some(value => { + if (!isObject(value)) { + return false; + } + const candidate = value as { keyId?: string; use?: string; algorithm?: string; publicKey?: string }; + return candidate.keyId === key.key_id && candidate.use === key.use && candidate.algorithm === key.algorithm && candidate.publicKey === key.public_key; + })); + if (!key) { + throw new Error('Mission Control MCP key does not match the connected host.'); + } + const value = handshakeMeta?.['copilot.authChallenge']; + const binding = isObject(value) ? value as { challenge?: unknown; required?: unknown } : undefined; + if (binding?.required === true && typeof binding.challenge !== 'string') { + throw new Error('Mission Control requires a missing handshake challenge.'); + } + const token = await seal({ + resource: request.resource, token: request.token, + key: { ...key, use: 'mcp-auth-token' }, + challenge: typeof binding?.challenge === 'string' ? binding.challenge : undefined, + }); + return { ...request, token }; +} diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/missionControlEnvironmentActions.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/missionControlEnvironmentActions.ts new file mode 100644 index 00000000000000..e7833e7e060081 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/missionControlEnvironmentActions.ts @@ -0,0 +1,134 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { isCancellationError } from '../../../../../base/common/errors.js'; +import { DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { localize, localize2 } from '../../../../../nls.js'; +import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { ICloudSandboxAgentHostService, ICloudSandboxApiService, type IMissionControlEnvironment } from '../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; +import { RemoteAgentHostsEnabledSettingId } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IAgentHostService } from '../../../../../platform/agentHost/common/agentService.js'; +import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; +import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; +import { type ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { INotificationService } from '../../../../../platform/notification/common/notification.js'; +import { IQuickInputService, type IQuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js'; + +export const ConnectMissionControlEnvironmentCommand = 'workbench.action.chat.connectMissionControlEnvironment'; + +interface IEnvironmentPick extends IQuickPickItem { + readonly environment: IMissionControlEnvironment; +} + +registerAction2(class extends Action2 { + constructor() { + super({ + id: ConnectMissionControlEnvironmentCommand, + title: localize2('connectMissionControlEnvironment', "Connect to Mission Control Environment..."), + f1: true, + precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals(`config.${RemoteAgentHostsEnabledSettingId}`, true)), + }); + } + + override async run(accessor: ServicesAccessor): Promise { + if (accessor.get(IChatEntitlementService).sentiment.hidden) { + throw new Error('Mission Control connections are unavailable when AI features are disabled.'); + } + const api = accessor.get(ICloudSandboxApiService); + const connections = accessor.get(ICloudSandboxAgentHostService); + const picker = accessor.get(IQuickInputService); + const notifications = accessor.get(INotificationService); + const local = accessor.get(IAgentHostService); + try { + const ownEnvironment = await local.getMissionControlEnvironmentId?.(); + const toItems = (environments: readonly IMissionControlEnvironment[]): IEnvironmentPick[] => environments + .filter(environment => environment.kind === 'user-local' && environment.id !== ownEnvironment) + .sort((a, b) => Number(b.status === 'online') - Number(a.status === 'online') || a.name.localeCompare(b.name)) + .map(environment => ({ label: environment.name, description: environment.status, detail: environment.id, environment })); + const resources = new DisposableStore(); + let selection: IEnvironmentPick | undefined; + try { + const cancellation = new CancellationTokenSource(); + resources.add(toDisposable(() => cancellation.dispose(true))); + const quickPick = resources.add(picker.createQuickPick()); + const cached = api.getCachedEnvironments(); + quickPick.title = localize('missionControlEnvironments', "Mission Control Environments"); + quickPick.placeholder = localize('selectMissionControlEnvironment', "Select an existing user-local host; no replacement compute is provisioned"); + quickPick.matchOnDescription = true; + quickPick.matchOnDetail = true; + quickPick.keepScrollPosition = true; + quickPick.items = toItems(cached ?? []); + quickPick.busy = cached === undefined; + selection = await new Promise((resolve, reject) => { + resources.add(quickPick.onDidAccept(() => { + const selected = quickPick.selectedItems[0] ?? quickPick.activeItems[0]; + if (selected) { + resolve(selected); + quickPick.hide(); + } + })); + resources.add(quickPick.onDidHide(() => { + cancellation.cancel(); + resolve(undefined); + })); + const refresh = async () => { + try { + const environments = await api.listEnvironments(cancellation.token, { refresh: true }); + if (cancellation.token.isCancellationRequested) { + return; + } + const active = new Set(quickPick.activeItems.map(item => item.environment.id)); + const items = toItems(environments); + quickPick.items = items; + if (active.size) { + quickPick.activeItems = items.filter(item => active.has(item.environment.id)); + } + quickPick.busy = false; + } catch (error) { + if (cancellation.token.isCancellationRequested) { + return; + } + if (isCancellationError(error)) { + quickPick.hide(); + return; + } + quickPick.busy = false; + if (!quickPick.items.length) { + reject(error); + quickPick.hide(); + } else { + notifications.error(error); + } + } + }; + quickPick.show(); + void refresh(); + }); + } finally { + resources.dispose(); + } + if (!selection) { + return; + } + const current = await api.getEnvironment(selection.environment.id, CancellationToken.None); + if (current.id !== selection.environment.id) { + throw new Error('Mission Control returned a different environment.'); + } + if (current.status !== 'online') { + notifications.warn(localize('missionControlEnvironmentOffline', "{0} is not online. Start its owning application before connecting.", selection.environment.name)); + return; + } + await connections.connect({ + environmentId: selection.environment.id, name: selection.environment.name, environmentKind: 'user-local', + }, CancellationToken.None); + } catch (error) { + if (!isCancellationError(error)) { + notifications.error(error); + } + } + } +}); diff --git a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHost.contribution.ts b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHost.contribution.ts index 38e5228c35159d..5497a3835c6a51 100644 --- a/src/vs/workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHost.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/remoteAgentHost/remoteAgentHost.contribution.ts @@ -4,20 +4,170 @@ *--------------------------------------------------------------------------------------------*/ import { localize } from '../../../../../nls.js'; +import { RunOnceScheduler, Throttler } from '../../../../../base/common/async.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { IAgentHostService } from '../../../../../platform/agentHost/common/agentService.js'; import { CloudSandboxEnabledSettingId, ICloudSandboxAgentHostService, ICloudSandboxApiService } from '../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; import { RemoteAgentHostsEnabledSettingId } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../../platform/configuration/common/configurationRegistry.js'; import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../common/contributions.js'; +import { IAuthenticationService } from '../../../../services/authentication/common/authentication.js'; +import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; import { CloudSandboxAgentHostService } from './cloudSandboxAgentHostService.js'; import { CloudSandboxApiService } from './cloudSandboxApiService.js'; import { CloudSandboxModelCatalogService, ICloudSandboxModelCatalogService } from './cloudSandboxModels.js'; import { CloudSandboxTelemetryService, ICloudSandboxTelemetryService } from './cloudSandboxTelemetry.js'; import { EditorCloudSandboxContribution } from './editorCloudSandboxContribution.js'; import { RemoteAgentHostContribution } from './remoteAgentHostChatContribution.js'; +import './missionControlEnvironmentActions.js'; import { IRemoteAgentHostConnectionCustomizationService, RemoteAgentHostConnectionCustomizationService } from './remoteAgentHostConnectionCustomization.js'; +const missionControlFakeEndpoint = 'chat.agentHost.experimentalMissionControlFakeEndpoint'; +const missionControlEnabled = 'chat.agentHost.experimentalMissionControl.enabled'; +const missionControlEndpoint = 'chat.agentHost.experimentalMissionControl.endpoint'; +const missionControlRequireBinding = 'chat.agentHost.experimentalMissionControl.requireConnectionBinding'; + +class MissionControlContribution extends Disposable { + static readonly ID = 'workbench.contrib.missionControl'; + private _configured = false; + private _generation = 0; + private _accountId: string | undefined; + private _accountSessionIds = new Set(); + private readonly _updates = new Throttler(); + private readonly _update = this._register(new RunOnceScheduler(() => { + void this._updates.queue(() => this._configure()).catch(error => this._logService.error('Mission Control configuration failed', error)); + }, 0)); + + constructor( + @IAgentHostService private readonly _agentHost: IAgentHostService, + @IConfigurationService private readonly _configuration: IConfigurationService, + @IAuthenticationService private readonly _authentication: IAuthenticationService, + @IChatEntitlementService private readonly _entitlement: IChatEntitlementService, + @IWorkspaceContextService private readonly _workspace: IWorkspaceContextService, + @IProductService private readonly _product: IProductService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + this._register(this._updates); + if (!this._agentHost.configureMissionControl) { + return; + } + this._register(this._configuration.onDidChangeConfiguration(e => { + if ([missionControlFakeEndpoint, missionControlEnabled, missionControlEndpoint, missionControlRequireBinding].some(setting => e.affectsConfiguration(setting))) { + this._withdraw(); + this._update.schedule(); + } + })); + this._register(this._workspace.onDidChangeWorkspaceFolders(() => { + this._generation++; + this._update.schedule(); + })); + this._register(this._authentication.onDidChangeSessions(e => { + const providerId = this._product.defaultChatAgent?.provider?.default?.id ?? 'github'; + if (e.providerId !== providerId) { + return; + } + const removed = e.event.removed?.filter(session => session.account.id === this._accountId) ?? []; + for (const session of removed) { + this._accountSessionIds.delete(session.id); + } + for (const session of e.event.added ?? []) { + if (session.account.id === this._accountId && this._getScopes().every(scope => session.scopes.includes(scope))) { + this._accountSessionIds.add(session.id); + } + } + if (removed.length && !this._accountSessionIds.size) { + this._withdraw(); + } else { + this._generation++; + } + this._update.schedule(); + })); + this._register(this._entitlement.onDidChangeSentiment(() => { + if (this._entitlement.sentiment.hidden) { + this._withdraw(); + } + this._update.schedule(); + })); + this._register(this._agentHost.onAgentHostStart(() => { + this._generation++; + this._configured = false; + this._update.schedule(); + })); + this._update.schedule(); + } + + private _withdraw(): void { + this._generation++; + this._configured = false; + this._accountSessionIds.clear(); + if (this._accountId !== undefined) { + void this._agentHost.configureMissionControl?.(undefined, this._accountId).catch(error => this._logService.error('Mission Control withdrawal failed', error)); + } + } + + private _getScopes(): readonly string[] { + return this._product.defaultChatAgent?.providerScopes?.[0] ?? ['read:user', 'user:email', 'repo', 'workflow']; + } + + private async _configure(): Promise { + const generation = this._generation; + const live = this._configuration.getValue(missionControlEnabled); + const endpoint = this._configuration.getValue(live ? missionControlEndpoint : missionControlFakeEndpoint); + if (live && this._configuration.getValue(missionControlFakeEndpoint)) { + this._withdraw(); + throw new Error('Disable the fake endpoint before enabling live Mission Control'); + } + if (!endpoint || this._entitlement.sentiment.hidden) { + if (this._configured) { + this._withdraw(); + } + return; + } + const roots = this._workspace.getWorkspace().folders.filter(folder => folder.uri.scheme === Schemas.file).map(folder => folder.uri.fsPath); + if (!roots.length && !live) { + if (this._configured) { + this._withdraw(); + } + throw new Error('Local Mission Control testing requires an open workspace'); + } + const providerId = this._product.defaultChatAgent?.provider?.default?.id ?? 'github'; + const scopes = this._getScopes(); + const sessions = await this._authentication.getSessions(providerId, [...scopes], undefined, true); + if (generation !== this._generation || this._store.isDisposed) { + return; + } + if (new Set(sessions.map(session => session.account.id)).size !== 1) { + if (this._configured) { + this._withdraw(); + } + throw new Error('Mission Control requires exactly one local GitHub account with Copilot scopes'); + } + this._agentHost.startAgentHost(); + if (generation !== this._generation) { + return; + } + this._accountId = sessions[0].account.id; + this._accountSessionIds = new Set(sessions.map(session => session.id)); + this._configured = true; + await this._agentHost.configureMissionControl?.({ + baseUrl: endpoint, + accountId: sessions[0].account.id, + credential: sessions[0].accessToken, + roots, + live, + requireConnectionBinding: this._configuration.getValue(missionControlRequireBinding), + }); + } +} + registerSingleton(ICloudSandboxTelemetryService, CloudSandboxTelemetryService, InstantiationType.Delayed); registerSingleton(ICloudSandboxApiService, CloudSandboxApiService, InstantiationType.Delayed); registerSingleton(ICloudSandboxModelCatalogService, CloudSandboxModelCatalogService, InstantiationType.Delayed); @@ -26,9 +176,41 @@ registerSingleton(IRemoteAgentHostConnectionCustomizationService, RemoteAgentHos registerWorkbenchContribution2(RemoteAgentHostContribution.ID, RemoteAgentHostContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(EditorCloudSandboxContribution.ID, EditorCloudSandboxContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(MissionControlContribution.ID, MissionControlContribution, WorkbenchPhase.AfterRestored); Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ properties: { + [missionControlEnabled]: { + type: 'boolean', + description: localize('missionControlEnabled', "Register the native Agent Host as a discoverable Mission Control environment so other clients can connect through Azure Web PubSub. Local VS Code continues using local IPC. Remote clients receive trusted-owner access including sessions, tools, and workspace resources. Native session actions are mirrored to Mission Control for catalog/history storage; conversation content is not end-to-end encrypted."), + default: false, + scope: ConfigurationScope.APPLICATION, + restricted: true, + tags: ['experimental', 'advanced'], + }, + [missionControlEndpoint]: { + type: 'string', + description: localize('missionControlEndpoint', "HTTPS Mission Control API origin. The local GitHub credential is sent to this origin; only configure an endpoint you trust."), + default: 'https://api.github.com', + scope: ConfigurationScope.APPLICATION, + restricted: true, + tags: ['experimental', 'advanced'], + }, + [missionControlRequireBinding]: { + type: 'boolean', + description: localize('missionControlRequireBinding', "Require remote relay clients to bind sealed authentication tokens to the current handshake challenge. Clients using Mission Control's pre-sealed tokens may not support this. Supplied bindings are always verified."), + default: false, + scope: ConfigurationScope.APPLICATION, + restricted: true, + tags: ['experimental', 'advanced'], + }, + [missionControlFakeEndpoint]: { + type: 'string', + description: localize('missionControlFakeEndpoint', "Register this Agent Host with a local Mission Control test server. Only loopback HTTP URLs are accepted. The local GitHub credential is sent to this endpoint; only use a test server you trust. Leave empty when using the real Mission Control service."), + default: '', + scope: ConfigurationScope.APPLICATION, + tags: ['experimental', 'advanced'], + }, [RemoteAgentHostsEnabledSettingId]: { type: 'boolean', description: localize('chat.remoteAgentHosts.enabled', "Enable connecting to remote agent hosts."), diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts index 39431f80179d4b..a9dfe7987f6589 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts @@ -33,6 +33,7 @@ import { IVoicePlaybackService } from '../../common/voicePlaybackService.js'; import { IAgentSessionsService } from '../agentSessions/agentSessionsService.js'; import { AgentSessionStatus } from '../agentSessions/agentSessionsModel.js'; import { toAgentHostBackendSessionUri } from '../agentSessions/agentHost/agentHostSessionUri.js'; +import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { ChatSendResult, ConfirmedReason, IChatConfirmation, IChatElicitationRequest, IChatPlanReview, IChatQuestionCarousel, IChatService, IChatToolInvocation, ToolConfirmKind, IChatModelReference } from '../../common/chatService/chatService.js'; import { getDisplayedQuestionText, getOptionsWithDefaultsFirst } from '../../common/chatService/chatQuestionCarouselHelpers.js'; import { formatQuestionPrompt } from '../../common/voiceClient/voicePendingNarration.js'; @@ -854,6 +855,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC @INotificationService private readonly notificationService: INotificationService, @IPromptsService private readonly promptsService: IPromptsService, @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, + @IAgentHostConnectionsService private readonly connectionsService: IAgentHostConnectionsService, ) { super(); @@ -4054,7 +4056,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC * indicator operate). No-op for non-agent-host resources. */ private _recordSessionAlias(uiResource: URI): void { - const backend = toAgentHostBackendSessionUri(uiResource); + const backend = toAgentHostBackendSessionUri(uiResource, this.connectionsService); if (!backend) { return; } @@ -4229,7 +4231,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._rekeySession(canonicalFrom, to); this._uiResourceByBackendId.set(from, to); - const previousBackend = toAgentHostBackendSessionUri(previous); + const previousBackend = toAgentHostBackendSessionUri(previous, this.connectionsService); if (previousBackend) { this._uiResourceByBackendId.set(previousBackend.toString(), to); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatAttachmentsContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatAttachmentsContentPart.ts index 16d8849f351ad5..8db9ed6f9dfda1 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatAttachmentsContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatAttachmentsContentPart.ts @@ -6,6 +6,7 @@ import * as dom from '../../../../../../base/browser/dom.js'; import { Emitter } from '../../../../../../base/common/event.js'; import { Disposable, DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { ResourceMap } from '../../../../../../base/common/map.js'; import { basename } from '../../../../../../base/common/path.js'; import { URI } from '../../../../../../base/common/uri.js'; import { Range } from '../../../../../../editor/common/core/range.js'; @@ -14,8 +15,9 @@ import { ResourceLabels } from '../../../../../browser/labels.js'; import { getImageAttachmentLimit, IChatRequestVariableEntry, isAgentHostCompletionVariableEntry, isBrowserViewVariableEntry, isElementVariableEntry, isImageVariableEntry, isNotebookOutputVariableEntry, isPasteVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry, isSCMHistoryItemChangeRangeVariableEntry, isSCMHistoryItemChangeVariableEntry, isSCMHistoryItemVariableEntry, isTerminalVariableEntry, isWorkspaceVariableEntry, OmittedState } from '../../../common/attachments/chatVariableEntries.js'; import { ChatResponseReferencePartStatusKind, IChatContentReference } from '../../../common/chatService/chatService.js'; import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService, isAutoLanguageModel } from '../../../common/languageModels.js'; -import { DefaultChatAttachmentWidget, ElementChatAttachmentWidget, FileAttachmentWidget, ImageAttachmentWidget, BrowserViewAttachmentWidget, NotebookCellOutputChatAttachmentWidget, PasteAttachmentWidget, PromptFileAttachmentWidget, PromptTextAttachmentWidget, SCMHistoryItemAttachmentWidget, SCMHistoryItemChangeAttachmentWidget, SCMHistoryItemChangeRangeAttachmentWidget, TerminalCommandAttachmentWidget, ToolSetOrToolItemAttachmentWidget } from '../../attachments/chatAttachmentWidgets.js'; +import { DefaultChatAttachmentWidget, ElementChatAttachmentWidget, FileAttachmentWidget, ImageAttachmentWidget, IChatImageBase64Data, BrowserViewAttachmentWidget, NotebookCellOutputChatAttachmentWidget, PasteAttachmentWidget, PromptFileAttachmentWidget, PromptTextAttachmentWidget, SCMHistoryItemAttachmentWidget, SCMHistoryItemChangeAttachmentWidget, SCMHistoryItemChangeRangeAttachmentWidget, TerminalCommandAttachmentWidget, ToolSetOrToolItemAttachmentWidget } from '../../attachments/chatAttachmentWidgets.js'; import { IChatAttachmentWidgetRegistry } from '../../attachments/chatAttachmentWidgetRegistry.js'; +import { IChatImageRevealOrigin } from '../../attachments/chatImageReveal.js'; export interface IChatAttachmentsContentPartOptions { readonly variables: readonly IChatRequestVariableEntry[]; @@ -25,6 +27,11 @@ export interface IChatAttachmentsContentPartOptions { readonly domNode?: HTMLElement; readonly limit?: number; readonly showImageInHover?: boolean; + /** Render original images directly instead of preparing attachment thumbnails. */ + readonly imagePresentation?: 'thumbnail' | 'inline'; + readonly imageReveal?: IChatImageRevealOrigin; + readonly imageDimensions?: ResourceMap; + readonly imageBase64Data?: ReadonlyMap; } export class ChatAttachmentsContentPart extends Disposable { @@ -40,6 +47,10 @@ export class ChatAttachmentsContentPart extends Disposable { private readonly resolvedModelId?: string; private readonly limit?: number; private readonly showImageInHover: boolean; + private readonly imagePresentation: IChatAttachmentsContentPartOptions['imagePresentation']; + private readonly imageReveal: IChatImageRevealOrigin | undefined; + private readonly imageDimensions: ResourceMap | undefined; + private readonly imageBase64Data: ReadonlyMap | undefined; public readonly domNode: HTMLElement | undefined; public contextMenuHandler?: (attachment: IChatRequestVariableEntry, event: MouseEvent) => void; @@ -57,6 +68,10 @@ export class ChatAttachmentsContentPart extends Disposable { this.resolvedModelId = options.resolvedModelId; this.limit = options.limit; this.showImageInHover = options.showImageInHover ?? true; + this.imagePresentation = options.imagePresentation; + this.imageReveal = options.imageReveal; + this.imageDimensions = options.imageDimensions; + this.imageBase64Data = options.imageBase64Data; this.domNode = options.domNode ?? dom.$('.chat-attached-context'); this._contextResourceLabels = this._register(this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this._onDidChangeVisibility.event })); @@ -230,7 +245,7 @@ export class ChatAttachmentsContentPart extends Disposable { const renderedAttachment = isAttachmentPartialOrOmitted || this.currentModelDoesNotSupportImages() ? { ...attachment, omittedState: OmittedState.Full } : attachment; - widget = this.instantiationService.createInstance(ImageAttachmentWidget, resource, renderedAttachment, this.getCurrentLanguageModel(), { shouldFocusClearButton: false, supportsDeletion: false, showImageInHover: this.showImageInHover }, container, this._contextResourceLabels); + widget = this.instantiationService.createInstance(ImageAttachmentWidget, resource, renderedAttachment, this.getCurrentLanguageModel(), { shouldFocusClearButton: false, supportsDeletion: false, showImageInHover: this.showImageInHover, imagePresentation: this.imagePresentation, imageReveal: this.imageReveal, imageDimensions: this.imageDimensions, imageBase64Data: this.imageBase64Data?.get(attachment.id) }, container, this._contextResourceLabels); } else if (isPromptFileVariableEntry(attachment)) { if (attachment.automaticallyAdded) { return; // Skip automatically added prompt files diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.ts index 58c7a05a006900..149ebc9603adf8 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.ts @@ -406,6 +406,18 @@ export function pickWorkingLabel(element: ChatTreeItem, configurationService: IC return label; } +const imageGenerationMessages = [ + localize('imageProgress.mixing', "Mixing the colors"), + localize('imageProgress.sketching', "Sketching the scene"), + localize('imageProgress.color', "Adding a splash of color"), + localize('imageProgress.pixels', "Bringing pixels to life"), + localize('imageProgress.possibilities', "Painting the possibilities"), + localize('imageProgress.brushstrokes', "Laying down the brushstrokes"), + localize('imageProgress.canvas', "Giving imagination a canvas"), + localize('imageProgress.ideas', "Turning ideas into pixels"), +]; +const imageGenerationMessageIntervalMs = 4000; + export class ChatWorkingProgressContentPart extends ChatProgressContentPart implements IChatContentPart { private explicitContent: IMarkdownString | undefined; private isActive: boolean; @@ -417,6 +429,9 @@ export class ChatWorkingProgressContentPart extends ChatProgressContentPart impl private readonly contextElement: ChatTreeItem; private readonly workingLogo: ChatWorkingProgressLogo | undefined; private readonly delayedProgressMessageScheduler: RunOnceScheduler | undefined; + private readonly imageGenerationMessageScheduler: RunOnceScheduler | undefined; + private imageGeneration: boolean; + private imageGenerationMessageIndex = 0; constructor( workingProgress: IChatWorkingProgress, @@ -430,10 +445,13 @@ export class ChatWorkingProgressContentPart extends ChatProgressContentPart impl ) { const explicitContent = workingProgress.content; const isActive = workingProgress.isActive ?? true; + const imageGeneration = !!context.suppressProgressShimmer && !!workingProgress.imageGeneration; const isInsiders = productService.quality === 'insider'; const progressMessage: IChatProgressMessage = { kind: 'progressMessage', - content: explicitContent ?? new MarkdownString().appendText(pickWorkingLabel(context.element, workingConfigurationService, workingProgress.progressStep)) + content: imageGeneration + ? new MarkdownString().appendText(imageGenerationMessages[0]) + : explicitContent ?? new MarkdownString().appendText(pickWorkingLabel(context.element, workingConfigurationService, workingProgress.progressStep)) }; super(progressMessage, chatContentMarkdownRenderer, context, context.suppressProgressShimmer ? isActive : undefined, @@ -458,16 +476,21 @@ export class ChatWorkingProgressContentPart extends ChatProgressContentPart impl } this.explicitContent = explicitContent; this.isActive = isActive; + this.imageGeneration = imageGeneration; this.progressStep = workingProgress.progressStep; this.showDelayedProgressMessage = workingProgress.showDelayedProgressMessage ?? false; this.contextElement = context.element; this.delayedProgressMessageScheduler = this.workingLogo ? this._register(new RunOnceScheduler(() => this.showDelayedProgress(), DELAYED_PROGRESS_MESSAGE_TIMEOUT_MS)) : undefined; + this.imageGenerationMessageScheduler = this.workingLogo + ? this._register(new RunOnceScheduler(() => this.rotateImageGenerationMessage(), imageGenerationMessageIntervalMs)) + : undefined; if (this.workingLogo && isResponseVM(context.element)) { this._register(context.element.model.onDidChange(() => this.onResponseActivity())); } this.updateDelayedProgressMessageScheduler(); + this.updateImageGenerationMessageScheduler(); // Keep the replacement anchor, but never leave disposed progress visible. this._register(toDisposable(() => hide(this.domNode))); @@ -483,12 +506,13 @@ export class ChatWorkingProgressContentPart extends ChatProgressContentPart impl setVisibility(!this.responseComplete, this.domNode); } this.updateActiveState(); + this.updateImageGenerationMessageScheduler(); this.onResponseActivity(); })); } this._register(languageModelToolsService.onDidPrepareToolCallBecomeUnresponsive(e => { - if (isEqual(context.element.sessionResource, e.sessionResource) && (!this.workingLogo || !this.explicitContent || this.showingUnresponsiveToolMessage)) { + if (!this.imageGeneration && isEqual(context.element.sessionResource, e.sessionResource) && (!this.workingLogo || !this.explicitContent || this.showingUnresponsiveToolMessage)) { this.updateWorkingContent(new MarkdownString(localize('toolCallUnresponsive', "Waiting for tool '{0}' to respond...", e.toolData.displayName)), true, false, this.progressStep, false); this.showingUnresponsiveToolMessage = true; } @@ -499,10 +523,11 @@ export class ChatWorkingProgressContentPart extends ChatProgressContentPart impl return renderAsPlaintext(this.currentContent); } - updateWorkingContent(content: IMarkdownString | undefined, isActive = this.isActive, announce: IChatWorkingProgress['announce'] = false, progressStep = this.progressStep, showDelayedProgressMessage = this.showDelayedProgressMessage): void { + updateWorkingContent(content: IMarkdownString | undefined, isActive = this.isActive, announce: IChatWorkingProgress['announce'] = false, progressStep = this.progressStep, showDelayedProgressMessage = this.showDelayedProgressMessage, imageGeneration = false): void { this.showingUnresponsiveToolMessage = false; const previousExplicitContent = this.explicitContent; const previousIsActive = this.isActive; + const previousImageGeneration = this.imageGeneration; const previousProgressStep = this.progressStep; const previousShowDelayedProgressMessage = this.showDelayedProgressMessage; const shouldResetDelayedProgress = previousExplicitContent?.value !== content?.value @@ -510,17 +535,23 @@ export class ChatWorkingProgressContentPart extends ChatProgressContentPart impl || (!previousShowDelayedProgressMessage && showDelayedProgressMessage); this.explicitContent = content; this.isActive = isActive; + this.imageGeneration = !!this.workingLogo && imageGeneration; + if (this.imageGeneration !== previousImageGeneration) { + this.imageGenerationMessageIndex = 0; + } this.progressStep = progressStep; this.showDelayedProgressMessage = showDelayedProgressMessage; if (!showDelayedProgressMessage || !isActive || shouldResetDelayedProgress) { this.showingDelayedProgressMessage = false; } this.updateDelayedProgressMessageScheduler(shouldResetDelayedProgress); + this.updateImageGenerationMessageScheduler(); const resolvedContent = this.resolveWorkingContent(); if (this.workingLogo && content?.value === previousExplicitContent?.value && resolvedContent.value === this.currentContent.value && isActive === previousIsActive) { return; } - const shouldAnnounce = announce && !!this.workingLogo && !!content && content.value !== previousExplicitContent?.value + const shouldAnnounce = announce && !!this.workingLogo + && ((!!content && content.value !== previousExplicitContent?.value) || (this.imageGeneration && !previousImageGeneration)) && this.workingConfigurationService.getValue(AccessibilityWorkbenchSettingId.VerboseChatProgressUpdates); if (this.workingLogo) { this.updateActiveState(); @@ -547,6 +578,9 @@ export class ChatWorkingProgressContentPart extends ChatProgressContentPart impl } private resolveWorkingContent(): IMarkdownString { + if (this.imageGeneration) { + return new MarkdownString().appendText(imageGenerationMessages[this.imageGenerationMessageIndex]); + } if (this.showingDelayedProgressMessage) { return new MarkdownString().appendText(localize('persistentProgress.takingLonger', "This is taking a little longer than usual")); } @@ -557,7 +591,7 @@ export class ChatWorkingProgressContentPart extends ChatProgressContentPart impl if (!this.delayedProgressMessageScheduler) { return; } - if (!this.showDelayedProgressMessage || !this.isActive || this.responseComplete) { + if (this.imageGeneration || !this.showDelayedProgressMessage || !this.isActive || this.responseComplete) { this.delayedProgressMessageScheduler.cancel(); return; } @@ -566,6 +600,28 @@ export class ChatWorkingProgressContentPart extends ChatProgressContentPart impl } } + private shouldRotateImageGenerationMessage(): boolean { + return this.imageGeneration && this.isActive + && !(isResponseVM(this.contextElement) && (this.contextElement.isComplete || this.contextElement.isCanceled)); + } + + private updateImageGenerationMessageScheduler(): void { + if (!this.shouldRotateImageGenerationMessage()) { + this.imageGenerationMessageScheduler?.cancel(); + } else if (!this.imageGenerationMessageScheduler?.isScheduled()) { + this.imageGenerationMessageScheduler?.schedule(); + } + } + + private rotateImageGenerationMessage(): void { + if (!this.shouldRotateImageGenerationMessage()) { + return; + } + this.imageGenerationMessageIndex = (this.imageGenerationMessageIndex + 1) % imageGenerationMessages.length; + this.updateMessage(this.resolveWorkingContent()); + this.updateImageGenerationMessageScheduler(); + } + private onResponseActivity(): void { if (!this.showDelayedProgressMessage || !this.isActive) { return; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatResourceGroupWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatResourceGroupWidget.ts index daa420276207ca..0df02296073753 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatResourceGroupWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatResourceGroupWidget.ts @@ -9,6 +9,7 @@ import { decodeBase64 } from '../../../../../../base/common/buffer.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../../base/common/event.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { ResourceMap } from '../../../../../../base/common/map.js'; import { basename, extname, joinPath } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; @@ -18,7 +19,7 @@ import { Action2, MenuId, registerAction2 } from '../../../../../../platform/act import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; import { IContextMenuService } from '../../../../../../platform/contextview/browser/contextView.js'; import { IFileDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; -import { IFileService } from '../../../../../../platform/files/common/files.js'; +import { FileSystemProviderCapabilities, IFileService } from '../../../../../../platform/files/common/files.js'; import { IInstantiationService, ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; @@ -27,6 +28,8 @@ import { IWorkspaceContextService } from '../../../../../../platform/workspace/c import { REVEAL_IN_EXPLORER_COMMAND_ID } from '../../../../files/browser/fileConstants.js'; import { CHAT_ATTACHABLE_IMAGE_MIME_TYPES, getAttachableImageExtension } from '../../../common/model/chatModel.js'; import { IChatRequestVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; +import { IChatImageRevealOrigin } from '../../attachments/chatImageReveal.js'; +import { IChatImageBase64Data } from '../../attachments/chatAttachmentWidgets.js'; import { ChatAttachmentsContentPart } from './chatAttachmentsContentPart.js'; import { IChatCollapsibleIODataPart } from './chatToolInputOutputContentPart.js'; @@ -42,9 +45,9 @@ const IMAGE_DECODE_DELAY_MS = 100; /** * A reusable widget for rendering a group of resource data parts (files, images) - * with attachment pills and a toolbar with save actions. + * with image previews, attachment pills, and shared save actions. * - * Used by ChatToolOutputContentSubPart and ChatMcpAppSubPart (for download resources). + * Inline image results bypass deferred attachment thumbnails while retaining the shared resource actions. */ export class ChatResourceGroupWidget extends Disposable { public readonly domNode: HTMLElement; @@ -53,7 +56,7 @@ export class ChatResourceGroupWidget extends Disposable { constructor( parts: IChatCollapsibleIODataPart[], - private readonly _options: { showImageInHover?: boolean } | undefined, + private readonly _options: { showImageInHover?: boolean; imagePresentation?: 'thumbnail' | 'inline'; imageReveal?: IChatImageRevealOrigin; imageDimensions?: ResourceMap } | undefined, @IInstantiationService private readonly _instantiationService: IInstantiationService, @IContextMenuService private readonly _contextMenuService: IContextMenuService, @IFileService private readonly _fileService: IFileService, @@ -70,15 +73,23 @@ export class ChatResourceGroupWidget extends Disposable { } private async _fillInResourceGroup(parts: IChatCollapsibleIODataPart[], itemsContainer: HTMLElement, actionsContainer: HTMLElement) { - // First pass: create entries immediately, using file placeholders for base64 images + // Only ordinary attachment thumbnails use the deferred file-placeholder path. const entries: IChatRequestVariableEntry[] = []; + const imageBase64Data = new Map(); const deferredImageParts: { index: number; part: IChatCollapsibleIODataPart; mimeType: string }[] = []; for (let i = 0; i < parts.length; i++) { const part = parts[i]; const imageMimeType = getResourceImageMimeType(part); if (imageMimeType) { - if (part.base64Value) { + if (this._options?.imagePresentation === 'inline') { + const id = generateUuid(); + if (part.base64Value !== undefined) { + imageBase64Data.set(id, { data: part.base64Value, mimeType: imageMimeType }); + } + const value = part.base64Value === undefined ? part.value : undefined; + entries.push({ kind: 'image', id, name: basename(part.uri), value: value ?? part.uri, mimeType: imageMimeType, isURL: !value && part.base64Value === undefined, references: [{ kind: 'reference', reference: part.uri }] }); + } else if (part.base64Value) { // Defer base64 decode - use file placeholder for now entries.push({ kind: 'file', id: generateUuid(), name: basename(part.uri), fullName: part.uri.path, value: part.uri }); deferredImageParts.push({ index: i, part, mimeType: imageMimeType }); @@ -101,7 +112,6 @@ export class ChatResourceGroupWidget extends Disposable { return; } - // Render attachments immediately with placeholders const attachments = this._register(this._instantiationService.createInstance( ChatAttachmentsContentPart, { @@ -110,6 +120,10 @@ export class ChatResourceGroupWidget extends Disposable { contentReferences: undefined, domNode: undefined, showImageInHover: this._options?.showImageInHover, + imagePresentation: this._options?.imagePresentation, + imageReveal: this._options?.imageReveal, + imageDimensions: this._options?.imageDimensions, + imageBase64Data, } )); @@ -196,8 +210,7 @@ class SaveResourcesAction extends Action2 { const labelService = accessor.get(ILabelService); const defaultFilepath = await fileDialog.defaultFilePath(); - const savePart = async (part: IChatCollapsibleIODataPart, isFolder: boolean, uri: URI) => { - const target = isFolder ? joinPath(uri, basename(part.uri)) : uri; + const savePart = async (part: IChatCollapsibleIODataPart, target: URI) => { try { if (part.kind === 'data') { await fileService.copy(part.uri, target, true); @@ -236,7 +249,7 @@ class SaveResourcesAction extends Action2 { if (!uri) { return; } - await withProgress(uri, [() => savePart(part, false, uri)]); + await withProgress(uri, [() => savePart(part, uri)]); } else { const uris = await fileDialog.showOpenDialog({ title: localize('chat.saveResources.title', "Pick folder to save resources"), @@ -250,7 +263,26 @@ class SaveResourcesAction extends Action2 { return; } - await withProgress(uris[0], context.parts.map(part => () => savePart(part, true, uris[0]))); + const folder = uris[0]; + const caseSensitive = fileService.hasCapability(folder, FileSystemProviderCapabilities.PathCaseSensitive); + const nameKey = (name: string) => caseSensitive ? name : name.toLowerCase(); + const reservedNames = new Set(context.parts.map(part => nameKey(basename(part.uri)))); + const usedNames = new Set(); + const saves = context.parts.map(part => { + const name = basename(part.uri); + let targetName = name; + if (usedNames.has(nameKey(targetName))) { + const extension = extname(part.uri); + const stem = name.slice(0, name.length - extension.length); + let suffix = 2; + do { + targetName = `${stem}-${suffix++}${extension}`; + } while (reservedNames.has(nameKey(targetName)) || usedNames.has(nameKey(targetName))); + } + usedNames.add(nameKey(targetName)); + return () => savePart(part, joinPath(folder, targetName)); + }); + await withProgress(folder, saves); } } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatToolInputOutputContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatToolInputOutputContentPart.ts index a6f249e9b04519..608f18bd8d9054 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatToolInputOutputContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatToolInputOutputContentPart.ts @@ -57,6 +57,7 @@ export type ChatCollapsibleIOPart = IChatCollapsibleIOCodePart | IChatCollapsibl export interface IChatCollapsibleInputData extends IChatCollapsibleIOCodePart { } export interface IChatCollapsibleOutputData { parts: ChatCollapsibleIOPart[]; + showCollapsedResources?: boolean; } export class ChatCollapsibleInputOutputContentPart extends Disposable { @@ -96,6 +97,7 @@ export class ChatCollapsibleInputOutputContentPart extends Disposable { isError: boolean, initiallyExpanded: boolean, shimmer: boolean, + icon: ThemeIcon | undefined, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IInstantiationService private readonly _instantiationService: IInstantiationService, @IHoverService hoverService: IHoverService, @@ -144,9 +146,9 @@ export class ChatCollapsibleInputOutputContentPart extends Disposable { if (isError) { btn.icon = Codicon.errorCompact; } else { - btn.icon = output + btn.icon = icon ?? (output ? Codicon.checkCompact - : ThemeIcon.modify(Codicon.loadingCompact, 'spin'); + : ThemeIcon.modify(Codicon.loadingCompact, 'spin')); } elements.root.classList.toggle('shimmer-progress', shimmer && isInProgress); @@ -182,7 +184,7 @@ export class ChatCollapsibleInputOutputContentPart extends Disposable { this._register(btn.onDidClick(toggle)); - const topLevelResources = this.output?.parts + const topLevelResources = this.output?.showCollapsedResources === false ? undefined : this.output?.parts .filter(p => p.kind === 'data') .filter(p => !p.audience || p.audience.includes(LanguageModelPartAudience.User)); if (topLevelResources?.length) { @@ -230,7 +232,22 @@ export class ChatCollapsibleInputOutputContentPart extends Disposable { return contents.root; } - private addCodeBlock(part: IChatCollapsibleIOCodePart, container: HTMLElement) { + updateInput(text: string): void { + if (this.input.data === text) { + return; + } + this.input.data = text; + const codeBlock = this._editorReferences[0]?.object; + if (codeBlock) { + const viewState = codeBlock.editor.saveViewState(); + this.renderCodeBlock(this.input, codeBlock); + if (viewState) { + codeBlock.editor.restoreViewState(viewState); + } + } + } + + private renderCodeBlock(part: IChatCollapsibleIOCodePart, codeBlock: CodeBlockPart): void { const data: ICodeBlockData = { languageId: part.languageId, text: part.data, @@ -240,9 +257,13 @@ export class ChatCollapsibleInputOutputContentPart extends Disposable { renderOptions: part.options, chatSessionResource: this.context.element.sessionResource, }; + codeBlock.render(data, (this._lastLayoutWidth ?? this.context.currentWidth.get()) || 300); + } + + private addCodeBlock(part: IChatCollapsibleIOCodePart, container: HTMLElement) { const key = CodeBlockPart.poolKey(this.context.element.id, part.codeBlockIndex); const editorReference = this._register(this.context.editorPool.get(key)); - editorReference.object.render(data, this.context.currentWidth.get() || 300); + this.renderCodeBlock(part, editorReference.object); container.appendChild(editorReference.object.element); this._editorReferences.push(editorReference); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatImageGenerationProgressPart.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatImageGenerationProgressPart.css new file mode 100644 index 00000000000000..c65267caa4b6f3 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatImageGenerationProgressPart.css @@ -0,0 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.chat-image-generation-placeholder { + margin: var(--vscode-spacing-size160) 0 var(--vscode-spacing-size40); +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css index 23d623514112df..d92184a86bd44d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css @@ -60,6 +60,14 @@ color: var(--vscode-descriptionForeground); } +/* Preserve the text-column width while anchoring artwork to the icon gutter. */ +.interactive-session .interactive-response .value .chat-tool-call-with-icon :is(.chat-image-generation-placeholder, .chat-generated-image-tool-result) { + position: relative; + left: calc(var(--vscode-codiconFontSize-compact) * 0.2); + margin-left: calc(-1 * var(--vscode-spacing-size240)); + margin-right: var(--vscode-spacing-size240); +} + .interactive-session .interactive-response.chat-persistent-progress .value .chat-tool-call-with-icon.chat-tool-call-error .progress-container > .codicon-error-compact, .interactive-session .interactive-response.chat-persistent-progress .value .chat-tool-call-error .chat-confirmation-widget-title > .codicon-error-compact, .interactive-session .interactive-response .value .chat-thinking-tool-wrapper .chat-tool-call-error .progress-container > .codicon-error-compact { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatGeneratedImageResultSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatGeneratedImageResultSubPart.ts index 0e6d31e87810b3..ae7aee91605b95 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatGeneratedImageResultSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatGeneratedImageResultSubPart.ts @@ -5,14 +5,16 @@ import * as dom from '../../../../../../../base/browser/dom.js'; import { Emitter } from '../../../../../../../base/common/event.js'; +import { ResourceMap } from '../../../../../../../base/common/map.js'; import { getExtensionForMimeType } from '../../../../../../../base/common/mime.js'; import { URI } from '../../../../../../../base/common/uri.js'; import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; import { IChatToolInvocation, IChatToolInvocationSerialized } from '../../../../common/chatService/chatService.js'; -import { ChatResponseResource } from '../../../../common/model/chatModel.js'; +import { ChatResponseResource, IChatProgressResponseContent } from '../../../../common/model/chatModel.js'; import { IChatRendererContent } from '../../../../common/model/chatViewModel.js'; import { isToolResultInputOutputDetails, type IToolResultInputOutputDetails } from '../../../../common/tools/languageModelToolsService.js'; import { type IChatCodeBlockInfo } from '../../../chat.js'; +import { IChatImageRevealOrigin } from '../../../attachments/chatImageReveal.js'; import { type IChatContentPartRenderContext } from '../chatContentParts.js'; import { ChatResourceGroupWidget } from '../chatResourceGroupWidget.js'; import { type IChatCollapsibleIODataPart } from '../chatToolInputOutputContentPart.js'; @@ -55,10 +57,36 @@ function getGeneratedImageResultDetails(toolInvocation: IChatToolInvocation | IC return isToolResultInputOutputDetails(resultDetails) ? resultDetails : undefined; } -export function getGeneratedImageResultCount(content: ReadonlyArray): number { +/** Collects completed top-level image results for the response gallery, excluding subagent tools. */ +export function getGeneratedImageResultSnapshot(content: ReadonlyArray): { readonly toolCallId: string; readonly details: IToolResultInputOutputDetails }[] { + const results: { toolCallId: string; details: IToolResultInputOutputDetails }[] = []; + for (const part of content) { + if ((part.kind !== 'toolInvocation' && part.kind !== 'toolInvocationSerialized') || part.subAgentInvocationId || part.toolSpecificData?.kind !== 'generatedImage' || !IChatToolInvocation.isComplete(part)) { + continue; + } + const details = getGeneratedImageResultDetails(part); + if (details) { + results.push({ toolCallId: part.toolCallId, details }); + } + } + return results; +} + +export function getLastGeneratedImageToolCallId(content: ReadonlyArray): string | undefined { + const lastImageTool = content.findLast(part => + (part.kind === 'toolInvocation' || part.kind === 'toolInvocationSerialized') + && !part.subAgentInvocationId + && part.toolSpecificData?.kind === 'generatedImage' + && IChatToolInvocation.isComplete(part)); + return lastImageTool?.kind === 'toolInvocation' || lastImageTool?.kind === 'toolInvocationSerialized' + ? lastImageTool.toolCallId + : undefined; +} + +export function getGeneratedImageResultCount(content: ReadonlyArray, subAgentInvocationId?: string): number { let count = 0; for (const part of content) { - if ((part.kind !== 'toolInvocation' && part.kind !== 'toolInvocationSerialized') || part.toolSpecificData?.kind !== 'generatedImage') { + if ((part.kind !== 'toolInvocation' && part.kind !== 'toolInvocationSerialized') || part.subAgentInvocationId !== subAgentInvocationId || part.toolSpecificData?.kind !== 'generatedImage') { continue; } const details = getGeneratedImageResultDetails(part); @@ -72,40 +100,63 @@ export function getGeneratedImageResultPartsFromContent( sessionResource: URI, ): IChatCollapsibleIODataPart[] { const parts: IChatCollapsibleIODataPart[] = []; - for (const part of content) { - if ((part.kind !== 'toolInvocation' && part.kind !== 'toolInvocationSerialized') || part.toolSpecificData?.kind !== 'generatedImage') { - continue; - } - parts.push(...getGeneratedImageResultParts(getGeneratedImageResultDetails(part), sessionResource, part.toolCallId)); + for (const { toolCallId, details } of getGeneratedImageResultSnapshot(content)) { + parts.push(...getGeneratedImageResultParts(details, sessionResource, toolCallId)); } if (parts.length < 2) { return parts; } - return parts.map((part, index) => ({ + return parts.map((part, index) => part.base64Value === undefined ? part : ({ ...part, - // Distinguish each attachment in the gallery's visible and accessible labels. + // Only synthetic names may change; referenced URIs identify the bytes to load and save. uri: part.uri.with({ path: part.uri.path.replace(/generated-image(?=\.[^/]+$|$)/, `generated-image-${index + 1}`) }), })); } /** Renders generated images as response outcomes using the shared image preview affordances. */ export class ChatGeneratedImageResultSubPart extends BaseChatToolInvocationSubPart { + private static readonly imageDimensions = new WeakMap>(); + public readonly domNode: HTMLElement; - public override readonly codeblocks: IChatCodeBlockInfo[] = []; + public readonly codeblocks: IChatCodeBlockInfo[] = []; private readonly _onDidChangeHeight = this._register(new Emitter()); public readonly onDidChangeHeight = this._onDidChangeHeight.event; constructor( toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized, context: IChatContentPartRenderContext, + imageReveal: IChatImageRevealOrigin | undefined, @IInstantiationService instantiationService: IInstantiationService, ) { super(toolInvocation); - const parts = getGeneratedImageResultPartsFromContent(context.content, context.element.sessionResource); - const resourceGroup = this._register(instantiationService.createInstance(ChatResourceGroupWidget, parts, { showImageInHover: false })); + this.domNode = dom.$('.chat-generated-image-tool-result'); + + if (!toolInvocation.subAgentInvocationId && getLastGeneratedImageToolCallId(context.content) !== toolInvocation.toolCallId) { + return; + } + + // Child tools retain their own results rather than claiming the response-level gallery. + const parts = toolInvocation.subAgentInvocationId + ? getGeneratedImageResultParts(getGeneratedImageResultDetails(toolInvocation), context.element.sessionResource, toolInvocation.toolCallId) + : getGeneratedImageResultPartsFromContent(context.content, context.element.sessionResource); + let imageDimensions = ChatGeneratedImageResultSubPart.imageDimensions.get(context.element); + if (!imageDimensions) { + imageDimensions = new ResourceMap(resource => { + const parsed = ChatResponseResource.parseUri(resource); + return parsed + ? ChatResponseResource.createUri(parsed.sessionResource, parsed.toolCallId, parsed.index).toString() + : resource.toString(); + }); + ChatGeneratedImageResultSubPart.imageDimensions.set(context.element, imageDimensions); + } + const resourceGroup = this._register(instantiationService.createInstance(ChatResourceGroupWidget, parts, { + showImageInHover: false, + imagePresentation: 'inline', + imageReveal: parts.length === 1 ? imageReveal : undefined, + imageDimensions, + })); this._register(resourceGroup.onDidChangeHeight(() => this._onDidChangeHeight.fire())); - this.domNode = dom.$('.chat-generated-image-result', undefined, resourceGroup.domNode); - const hasMultipleGeneratedImages = getGeneratedImageResultCount(context.content) > 1; - this.domNode.classList.toggle('multiple', hasMultipleGeneratedImages); + const gallery = dom.append(this.domNode, dom.$('.chat-generated-image-result', undefined, resourceGroup.domNode)); + gallery.classList.toggle('multiple', parts.length > 1); } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatImageGenerationProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatImageGenerationProgressPart.ts new file mode 100644 index 00000000000000..c2b3b04ff6c222 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatImageGenerationProgressPart.ts @@ -0,0 +1,127 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../../../base/browser/dom.js'; +import { Emitter } from '../../../../../../../base/common/event.js'; +import { MutableDisposable } from '../../../../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../../../../base/common/observable.js'; +import { localize } from '../../../../../../../nls.js'; +import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; +import { IChatToolInvocation, IChatToolInvocationSerialized } from '../../../../common/chatService/chatService.js'; +import { IChatCodeBlockInfo } from '../../../chat.js'; +import { IChatImageRevealOrigin } from '../../../attachments/chatImageReveal.js'; +import { GlyphSurface } from '../../../attachments/chatImageGlyphSurface.js'; +import { IChatContentPartRenderContext } from '../chatContentParts.js'; +import { ChatInputOutputMarkdownProgressPart } from './chatInputOutputMarkdownProgressPart.js'; +import { BaseChatToolInvocationSubPart } from './chatToolInvocationSubPart.js'; +import { createImageGenerationLabel, getImageGenerationInvocationMessage } from './chatToolPartUtilities.js'; +import '../media/chatImageGenerationProgressPart.css'; +import '../../../attachments/chatImageReveal.css'; + +export class ChatImageGenerationToolProgressPart extends BaseChatToolInvocationSubPart { + public readonly domNode: HTMLElement; + private readonly details = this._register(new MutableDisposable()); + private readonly animation = this._register(new MutableDisposable()); + private readonly _onDidChangeHeight = this._register(new Emitter()); + public readonly onDidChangeHeight = this._onDidChangeHeight.event; + + public get codeblocks(): IChatCodeBlockInfo[] { + return this.details.value?.codeblocks ?? []; + } + + public override get codeblocksPartId(): string { + return this.details.value?.codeblocksPartId ?? super.codeblocksPartId; + } + + constructor( + toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized, + context: IChatContentPartRenderContext, + codeBlockStartIndex: number, + showAnimation: boolean, + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { + super(toolInvocation); + + this.domNode = dom.$('.chat-image-generation-progress'); + const detailsContainer = dom.append(this.domNode, dom.$('div')); + let previousInput: string | undefined; + let previousMessage: string | undefined; + this._register(autorun(reader => { + const state = toolInvocation.kind === 'toolInvocation' ? toolInvocation.state.read(reader) : undefined; + const input: unknown = state?.type === IChatToolInvocation.StateKind.Streaming + ? state.partialInput.read(reader) + : toolInvocation.toolSpecificData?.kind === 'input' + ? toolInvocation.toolSpecificData.rawInput + : IChatToolInvocation.getParameters(toolInvocation); + const inputText = (typeof input === 'string' ? input : JSON.stringify(input, null, 2)) + ?? localize('imageGeneration.inputUnavailable', "The image prompt is not available yet."); + const message = getImageGenerationInvocationMessage(toolInvocation, reader); + if (!this.details.value) { + const details = this.details.value = instantiationService.createInstance( + ChatInputOutputMarkdownProgressPart, + toolInvocation, + context, + codeBlockStartIndex, + createImageGenerationLabel(message), + toolInvocation.originMessage, + inputText, + 'json', + undefined, + false, + ); + dom.reset(detailsContainer, details.domNode); + } else { + if (inputText !== previousInput) { + this.details.value.updateInput(inputText); + } + if (message !== previousMessage) { + this.details.value.title = createImageGenerationLabel(message); + } + } + if (inputText !== previousInput || message !== previousMessage) { + previousInput = inputText; + previousMessage = message; + this._onDidChangeHeight.fire(); + } + })); + + this.setShowAnimation(showAnimation); + } + + public getRevealOrigin(container: HTMLElement): IChatImageRevealOrigin | undefined { + return this.animation.value ? { container } : undefined; + } + + public setShowAnimation(show: boolean): void { + if (show === !!this.animation.value) { + return; + } + if (show) { + const animation = this.animation.value = this.instantiationService.createInstance(ChatImageGenerationProgressPart, this.toolInvocation); + this.domNode.appendChild(animation.domNode); + } else { + this.animation.value?.domNode.remove(); + this.animation.clear(); + } + this._onDidChangeHeight.fire(); + } +} + +export class ChatImageGenerationProgressPart extends BaseChatToolInvocationSubPart { + public readonly domNode: HTMLElement; + public readonly codeblocks: IChatCodeBlockInfo[] = []; + + constructor( + toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(toolInvocation); + + const label = localize('chat.imageGeneration.placeholder', "Generating image"); + this.domNode = dom.$('.chat-image-generation-placeholder', { role: 'img', 'aria-label': label, 'aria-busy': 'true' }); + const line = dom.append(this.domNode, dom.$('.chat-image-generation-line', { 'aria-hidden': 'true' })); + this._register(instantiationService.createInstance(GlyphSurface, line)); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatInputOutputMarkdownProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatInputOutputMarkdownProgressPart.ts index c0c1eb7b1a40c5..4f310790f11d7e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatInputOutputMarkdownProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatInputOutputMarkdownProgressPart.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { ProgressBar } from '../../../../../../../base/browser/ui/progressbar/progressbar.js'; +import { Codicon } from '../../../../../../../base/common/codicons.js'; import { IMarkdownString } from '../../../../../../../base/common/htmlContent.js'; import { Lazy } from '../../../../../../../base/common/lazy.js'; import { toDisposable } from '../../../../../../../base/common/lifecycle.js'; @@ -21,7 +22,7 @@ import { IChatCodeBlockInfo } from '../../../chat.js'; import { IChatContentPartRenderContext } from '../chatContentParts.js'; import { ChatCollapsibleInputOutputContentPart, ChatCollapsibleIOPart, IChatCollapsibleIOCodePart } from '../chatToolInputOutputContentPart.js'; import { BaseChatToolInvocationSubPart } from './chatToolInvocationSubPart.js'; -import { getToolApprovalMessage, shouldShimmerForTool } from './chatToolPartUtilities.js'; +import { getToolApprovalMessage, isImageGenerationToolInvocation, shouldShimmerForTool } from './chatToolPartUtilities.js'; export class ChatInputOutputMarkdownProgressPart extends BaseChatToolInvocationSubPart { /** Remembers expanded tool parts on re-render */ @@ -30,10 +31,18 @@ export class ChatInputOutputMarkdownProgressPart extends BaseChatToolInvocationS public readonly domNode: HTMLElement; private readonly collapsibleListPart: ChatCollapsibleInputOutputContentPart; + public set title(message: string | IMarkdownString) { + this.collapsibleListPart.title = message; + } + public get codeblocks(): IChatCodeBlockInfo[] { return this.collapsibleListPart.codeblocks; } + public updateInput(input: string): void { + this.collapsibleListPart.updateInput(input); + } + constructor( toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized, context: IChatContentPartRenderContext, @@ -51,6 +60,7 @@ export class ChatInputOutputMarkdownProgressPart extends BaseChatToolInvocationS super(toolInvocation); let codeBlockIndex = codeBlockStartIndex; + const isImageGeneration = isImageGenerationToolInvocation(toolInvocation); // Simple factory to create code part data objects const createCodePart = (data: string, languageId = 'json'): IChatCollapsibleIOCodePart => ({ @@ -104,6 +114,7 @@ export class ChatInputOutputMarkdownProgressPart extends BaseChatToolInvocationS context, createCodePart(input, inputLanguage), processedOutput && processedOutput.length > 0 ? { + showCollapsedResources: !isImageGeneration, parts: processedOutput.map((o, i): ChatCollapsibleIOPart => { const permalinkBasename = o.type === 'ref' || o.uri ? basename(o.uri!) @@ -133,10 +144,11 @@ export class ChatInputOutputMarkdownProgressPart extends BaseChatToolInvocationS isError, ChatInputOutputMarkdownProgressPart._expandedByDefault.get(toolInvocation) ?? false, shouldShimmerForTool(toolInvocation, message), + isImageGeneration ? Codicon.fileMedia : undefined, )); this._register(toDisposable(() => ChatInputOutputMarkdownProgressPart._expandedByDefault.set(toolInvocation, collapsibleListPart.expanded))); - const progressObservable = toolInvocation.kind === 'toolInvocation' ? toolInvocation.state.map((s, r) => s.type === IChatToolInvocation.StateKind.Executing ? s.progress.read(r) : undefined) : undefined; + const progressObservable = toolInvocation.kind === 'toolInvocation' && !isImageGeneration ? toolInvocation.state.map((s, r) => s.type === IChatToolInvocation.StateKind.Executing ? s.progress.read(r) : undefined) : undefined; const progressBar = new Lazy(() => this._register(new ProgressBar(collapsibleListPart.domNode))); if (progressObservable) { this._register(autorun(reader => { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatSimpleToolProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatSimpleToolProgressPart.ts index b75c3ede29ee91..bbdb2c2d15d8d2 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatSimpleToolProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatSimpleToolProgressPart.ts @@ -79,6 +79,7 @@ export class ChatSimpleToolProgressPart extends BaseChatToolInvocationSubPart { isError, ChatSimpleToolProgressPart._expandedByDefault.get(toolInvocation) ?? false, shouldShimmerForTool(toolInvocation, message), + undefined, )); this._register(toDisposable(() => ChatSimpleToolProgressPart._expandedByDefault.set(toolInvocation, collapsibleListPart.expanded))); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts index cd61a8ee1e0fa2..e01c0778cce043 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts @@ -4,11 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../../../../base/browser/dom.js'; +import { equals } from '../../../../../../../base/common/arrays.js'; import { Codicon } from '../../../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../../../base/common/event.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../../../../base/common/lifecycle.js'; -import { autorun, constObservable, derivedOpts, IObservable, observableValue } from '../../../../../../../base/common/observable.js'; +import { autorun, constObservable, derivedOpts, IObservable, IReader, observableValue } from '../../../../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../../../../base/common/themables.js'; +import { localize } from '../../../../../../../nls.js'; import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; import { IMarkdownRenderer } from '../../../../../../../platform/markdown/browser/markdownRenderer.js'; @@ -27,7 +29,8 @@ import { ChatInputOutputMarkdownProgressPart } from './chatInputOutputMarkdownPr import { ChatMcpAppSubPart, IMcpAppRenderData } from './chatMcpAppSubPart.js'; import { ChatResultListSubPart } from './chatResultListSubPart.js'; import { ChatAutomationConfiguredResultSubPart } from './chatAutomationConfiguredResultSubPart.js'; -import { ChatGeneratedImageResultSubPart } from './chatGeneratedImageResultSubPart.js'; +import { ChatGeneratedImageResultSubPart, getGeneratedImageResultCount, getGeneratedImageResultSnapshot, getLastGeneratedImageToolCallId } from './chatGeneratedImageResultSubPart.js'; +import { ChatImageGenerationToolProgressPart } from './chatImageGenerationProgressPart.js'; import { ChatSessionCreatedResultSubPart } from './chatSessionCreatedResultSubPart.js'; import { ChatSimpleToolProgressPart } from './chatSimpleToolProgressPart.js'; import { ChatSandboxPrerequisiteConfirmationSubPart } from './chatSandboxPrerequisiteConfirmationSubPart.js'; @@ -43,7 +46,7 @@ import { ChatToolPostExecuteConfirmationPart } from './chatToolPostExecuteConfir import { ChatToolProgressSubPart } from './chatToolProgressPart.js'; import { ChatToolStreamingSubPart } from './chatToolStreamingSubPart.js'; import { ChatOtherClientToolProgressPart } from './chatOtherClientToolProgressPart.js'; -import { getToolInvocationIcon, hasToolInvocationError, isCarouselToolConfirmation } from './chatToolPartUtilities.js'; +import { createImageGenerationLabel, getToolInvocationIcon, hasToolInvocationError, isCarouselToolConfirmation, isImageGenerationToolInProgress, isImageGenerationToolInvocation } from './chatToolPartUtilities.js'; /** * Value equality for {@link IMcpAppRenderData}, used so the App's derived @@ -75,8 +78,8 @@ export function shouldRenderSessionCreatedResult(toolSpecificDataKind: string | return toolSpecificDataKind === 'sessionCreated' && isResponseComplete; } -export function shouldRenderGeneratedImageResult(toolSpecificDataKind: string | undefined, isResponseComplete: boolean): boolean { - return toolSpecificDataKind === 'generatedImage' && isResponseComplete; +export function shouldRenderGeneratedImageResult(toolSpecificDataKind: string | undefined, isToolComplete: boolean): boolean { + return toolSpecificDataKind === 'generatedImage' && isToolComplete; } export class ChatToolInvocationPart extends Disposable implements IChatContentPart { @@ -102,14 +105,17 @@ export class ChatToolInvocationPart extends Disposable implements IChatContentPa public acceptConfirmation(): void { if (this.toolInvocation.kind === 'toolInvocation' && this.toolInvocation.state.get().type === IChatToolInvocation.StateKind.WaitingForConfirmation) { - this.subPart.acceptConfirmation(); + this.subPart?.acceptConfirmation(); } } - private subPart!: BaseChatToolInvocationSubPart; + private subPart: BaseChatToolInvocationSubPart | undefined; private readonly mcpAppPart = this._register(new MutableDisposable()); private readonly renderedSessionCreatedResult: boolean; - private readonly renderedGeneratedImageResult: boolean; + private renderedGeneratedImageResult: boolean; + private renderedLastGeneratedImageToolCallId: string | undefined; + private renderedGeneratedImageResults: ReturnType; + private imageGenerationProgressSuppressed: boolean; private readonly _onDidRemount = this._register(new Emitter()); private readonly _onDidChangeHeight = this._register(new Emitter()); @@ -137,8 +143,12 @@ export class ChatToolInvocationPart extends Disposable implements IChatContentPa ); this.renderedGeneratedImageResult = shouldRenderGeneratedImageResult( toolInvocation.toolSpecificData?.kind, - isResponseVM(context.element) && context.element.isComplete, + IChatToolInvocation.isComplete(toolInvocation), ); + this.renderedLastGeneratedImageToolCallId = toolInvocation.subAgentInvocationId ? undefined : getLastGeneratedImageToolCallId(context.content); + this.renderedGeneratedImageResults = this.renderedGeneratedImageResult && this.renderedLastGeneratedImageToolCallId === toolInvocation.toolCallId + ? getGeneratedImageResultSnapshot(context.content) : []; + this.imageGenerationProgressSuppressed = this.shouldSuppressImageGenerationProgress(); this.domNode = dom.$('.chat-tool-invocation-part'); this._register(autorun(reader => { dom.setVisibility(this.isVisible.read(reader), this.domNode); @@ -150,8 +160,7 @@ export class ChatToolInvocationPart extends Disposable implements IChatContentPa return; } - const toolIcon = !context.inToolConfirmationCarousel ? dom.append(this.domNode, dom.$('span.chat-tool-call-icon', { 'aria-hidden': 'true' })) : undefined; - this.domNode.classList.toggle('chat-tool-call-with-icon', !!toolIcon); + const toolIcon = !context.inToolConfirmationCarousel ? dom.$('span.chat-tool-call-icon', { 'aria-hidden': 'true' }) : undefined; // Update the todo list service if this tool invocation contains todo data if (toolInvocation.toolSpecificData?.kind === 'todoList') { @@ -177,6 +186,7 @@ export class ChatToolInvocationPart extends Disposable implements IChatContentPa const state = toolInvocation.state.read(reader); const dataKind = toolInvocation.toolSpecificDataKind.read(reader); const toolSpecificData = toolInvocation.toolSpecificData; + const imageGenerationProgressSuppressed = this.shouldSuppressImageGenerationProgress(reader); const stateChanged = state.type !== previousState.type; const dataKindChanged = dataKind !== previousDataKind; const dataChanged = state !== previousState && toolSpecificData !== previousToolSpecificData; @@ -186,7 +196,7 @@ export class ChatToolInvocationPart extends Disposable implements IChatContentPa previousState = state; previousDataKind = dataKind; previousToolSpecificData = toolSpecificData; - if (stateChanged || dataKindChanged || dataChanged || confirmationMessagesChanged) { + if (stateChanged || dataKindChanged || dataChanged || confirmationMessagesChanged || imageGenerationProgressSuppressed !== this.imageGenerationProgressSuppressed) { render(); } })); @@ -229,28 +239,64 @@ export class ChatToolInvocationPart extends Disposable implements IChatContentPa this.domNode.appendChild(subPartDomNode); const render = () => { + const wasGeneratingImage = this.subPart instanceof ChatImageGenerationToolProgressPart; + const imageReveal = this.subPart instanceof ChatImageGenerationToolProgressPart ? this.subPart.getRevealOrigin(this.domNode) : undefined; + const isGeneratingImage = isImageGenerationToolInProgress(toolInvocation); + this.imageGenerationProgressSuppressed = this.shouldSuppressImageGenerationProgress(); + if (this.subPart instanceof ChatImageGenerationToolProgressPart && isGeneratingImage) { + this.subPart.setShowAnimation(!this.imageGenerationProgressSuppressed); + return; + } partStore.clear(); + this.subPart = undefined; const error = hasToolInvocationError(toolInvocation); this.domNode.classList.toggle('chat-tool-call-error', !!error); - if (toolIcon) { - const message = IChatToolInvocation.isComplete(toolInvocation) ? toolInvocation.pastTenseMessage ?? toolInvocation.invocationMessage : undefined; - const icon = error ? Codicon.error : getToolInvocationIcon(toolInvocation.toolId, toolInvocation, typeof message === 'string' ? message : message?.value); - toolIcon.className = 'chat-tool-call-icon'; - toolIcon.classList.add(...ThemeIcon.asClassNameArray(getCompactCodicon(icon))); - } - - if (toolInvocation.presentation === ToolInvocationPresentation.Hidden || (toolInvocation.presentation === ToolInvocationPresentation.HiddenAfterComplete && IChatToolInvocation.isComplete(toolInvocation))) { + this.renderedGeneratedImageResult = shouldRenderGeneratedImageResult(toolInvocation.toolSpecificData?.kind, IChatToolInvocation.isComplete(toolInvocation)); + const isSingleImage = isImageGenerationToolInvocation(toolInvocation) && !error + && (isGeneratingImage || (this.renderedGeneratedImageResult && getGeneratedImageResultCount(toolInvocation.subAgentInvocationId ? [toolInvocation] : context.content, toolInvocation.subAgentInvocationId) === 1)); + this.domNode.classList.toggle('chat-image-generation-single', isSingleImage); + this.renderedLastGeneratedImageToolCallId = toolInvocation.subAgentInvocationId ? undefined : getLastGeneratedImageToolCallId(context.content); + this.renderedGeneratedImageResults = this.renderedGeneratedImageResult && this.renderedLastGeneratedImageToolCallId === toolInvocation.toolCallId + ? getGeneratedImageResultSnapshot(context.content) : []; + if (toolInvocation.presentation === ToolInvocationPresentation.Hidden + || (toolInvocation.presentation === ToolInvocationPresentation.HiddenAfterComplete && IChatToolInvocation.isComplete(toolInvocation))) { this._isVisible.set(false, undefined); return; } - this.subPart = partStore.add(this.createToolInvocationSubPart()); + const subPart = this.subPart = partStore.add(this.createToolInvocationSubPart()); + if (subPart instanceof ChatImageGenerationToolProgressPart) { + partStore.add(subPart.onDidChangeHeight(() => this._onDidChangeHeight.fire())); + } partStore.add(autorun(reader => { - this._isVisible.set(!this.defersConfirmationToCarousel() && (!this.subPart.isHidden.read(reader) || !!appData.read(reader)), undefined); + this._isVisible.set(!this.defersConfirmationToCarousel() && (!subPart.isHidden.read(reader) || !!appData.read(reader)), undefined); })); subPartDomNode.replaceWith(this.subPart.domNode); subPartDomNode = this.subPart.domNode; + const resultDetails = IChatToolInvocation.resultDetails(toolInvocation); + if (this.renderedGeneratedImageResult && isToolResultInputOutputDetails(resultDetails) && !resultDetails.isError) { + const imageResult = partStore.add(this.instantiationService.createInstance(ChatGeneratedImageResultSubPart, toolInvocation, context, imageReveal)); + this.subPart.domNode.appendChild(imageResult.domNode); + partStore.add(imageResult.onDidChangeHeight(() => this._onDidChangeHeight.fire())); + } + + this.domNode.classList.toggle('generated-image-tool-invocation', this.renderedGeneratedImageResult); + this.domNode.classList.toggle('chat-tool-call-with-icon', !!toolIcon); + if (toolIcon) { + const message = IChatToolInvocation.isComplete(toolInvocation) ? toolInvocation.pastTenseMessage ?? toolInvocation.invocationMessage : undefined; + const icon = error ? Codicon.error : getToolInvocationIcon(toolInvocation.toolId, { + icon: toolInvocation.icon ?? (isImageGenerationToolInvocation(toolInvocation) ? Codicon.fileMedia : undefined), + source: toolInvocation.source, + toolSpecificData: toolInvocation.toolSpecificData, + }, typeof message === 'string' ? message : message?.value); + toolIcon.className = 'chat-tool-call-icon'; + toolIcon.classList.add(...ThemeIcon.asClassNameArray(getCompactCodicon(icon))); + if (!toolIcon.parentElement) { + this.domNode.prepend(toolIcon); + } + } + // Add class when displaying a confirmation widget const isConfirmation = this.subPart instanceof ToolConfirmationSubPart || this.subPart instanceof ChatTerminalToolConfirmationSubPart || @@ -262,8 +308,8 @@ export class ChatToolInvocationPart extends Disposable implements IChatContentPa this.domNode.classList.toggle('has-confirmation', isConfirmation); partStore.add(this.subPart.onNeedsRerender(render)); - if (this.subPart instanceof ChatGeneratedImageResultSubPart) { - partStore.add(this.subPart.onDidChangeHeight(() => this._onDidChangeHeight.fire())); + if (wasGeneratingImage !== isGeneratingImage) { + this._onDidChangeHeight.fire(); } }; @@ -293,6 +339,28 @@ export class ChatToolInvocationPart extends Disposable implements IChatContentPa render(); } + private shouldSuppressImageGenerationProgress(reader?: IReader): boolean { + if (this.toolInvocation.kind !== 'toolInvocation' || this.toolInvocation.otherClientToolCall + || !isImageGenerationToolInProgress(this.toolInvocation, this.toolInvocation.state.read(reader))) { + return false; + } + + const content = isResponseVM(this.context.element) ? this.context.element.response.value : this.context.content; + for (const part of content) { + if (part.kind !== 'toolInvocation') { + continue; + } + if (part.toolCallId === this.toolInvocation.toolCallId) { + break; + } + if (!part.otherClientToolCall && part.subAgentInvocationId === this.toolInvocation.subAgentInvocationId + && isImageGenerationToolInvocation(part) && isImageGenerationToolInProgress(part, part.state.read(reader))) { + return true; + } + } + return false; + } + /** * Transcript copies of a confirmation defer to the carousel above the input whenever it is enabled * for the tool and a chat widget exists to host it; only the carousel's own copy renders it. @@ -314,6 +382,10 @@ export class ChatToolInvocationPart extends Disposable implements IChatContentPa } const state = this.toolInvocation.state.get(); + if (isImageGenerationToolInProgress(this.toolInvocation, state)) { + return this.instantiationService.createInstance(ChatImageGenerationToolProgressPart, this.toolInvocation, this.context, this.codeBlockStartIndex, !this.imageGenerationProgressSuppressed); + } + // Handle streaming state - show streaming progress if (state.type === IChatToolInvocation.StateKind.Streaming) { return this.instantiationService.createInstance(ChatToolStreamingSubPart, this.toolInvocation, this.context, this.renderer); @@ -344,10 +416,6 @@ export class ChatToolInvocationPart extends Disposable implements IChatContentPa return this.instantiationService.createInstance(ChatSessionCreatedResultSubPart, this.toolInvocation, this.toolInvocation.toolSpecificData, this.context, this.renderer); } - if (this.renderedGeneratedImageResult && this.toolInvocation.toolSpecificData?.kind === 'generatedImage') { - return this.instantiationService.createInstance(ChatGeneratedImageResultSubPart, this.toolInvocation, this.context); - } - if (this.toolInvocation.toolSpecificData?.kind === 'automationConfigured') { return this.instantiationService.createInstance(ChatAutomationConfiguredResultSubPart, this.toolInvocation, this.toolInvocation.toolSpecificData, this.context, this.renderer); } @@ -384,17 +452,57 @@ export class ChatToolInvocationPart extends Disposable implements IChatContentPa } if (isToolResultInputOutputDetails(resultDetails)) { + let message = this.toolInvocation.pastTenseMessage ?? this.toolInvocation.invocationMessage; + const isImageGeneration = isImageGenerationToolInvocation(this.toolInvocation); + const isError = isImageGeneration ? hasToolInvocationError(this.toolInvocation) : !!resultDetails.isError; + if (isImageGeneration) { + const confirmation = IChatToolInvocation.executionConfirmedOrDenied(this.toolInvocation); + if (confirmation?.type !== ToolConfirmKind.Denied && confirmation?.type !== ToolConfirmKind.Skipped) { + if (isError) { + message = localize('imageGeneration.failed', "Generated image failed"); + } else if (this.renderedGeneratedImageResult) { + message = this.toolInvocation.pastTenseMessage ?? localize('imageGeneration.generated', "Generated image"); + if (typeof message === 'string') { + message = createImageGenerationLabel(message); + } + } + } + } return this.instantiationService.createInstance( ChatInputOutputMarkdownProgressPart, this.toolInvocation, this.context, this.codeBlockStartIndex, - this.toolInvocation.pastTenseMessage ?? this.toolInvocation.invocationMessage, + message, this.toolInvocation.originMessage, resultDetails.input, resultDetails.inputLanguage, resultDetails.output, - !!resultDetails.isError, + isError, + ); + } + + if (isImageGenerationToolInvocation(this.toolInvocation)) { + const state = this.toolInvocation.kind === 'toolInvocation' ? this.toolInvocation.state.get() : undefined; + const input: unknown = this.toolInvocation.toolSpecificData?.kind === 'input' + ? this.toolInvocation.toolSpecificData.rawInput + : IChatToolInvocation.getParameters(this.toolInvocation); + const confirmation = IChatToolInvocation.executionConfirmedOrDenied(this.toolInvocation); + const cancelled = confirmation?.type === ToolConfirmKind.Denied || confirmation?.type === ToolConfirmKind.Skipped; + const message = cancelled + ? this.toolInvocation.pastTenseMessage ?? (state?.type === IChatToolInvocation.StateKind.Cancelled ? state.reasonMessage : undefined) ?? localize('imageGeneration.cancelled', "Image generation cancelled") + : this.toolInvocation.pastTenseMessage ?? this.toolInvocation.invocationMessage; + return this.instantiationService.createInstance( + ChatInputOutputMarkdownProgressPart, + this.toolInvocation, + this.context, + this.codeBlockStartIndex, + message, + this.toolInvocation.originMessage, + typeof input === 'string' ? input : JSON.stringify(input, null, 2) ?? '', + undefined, + undefined, + false, ); } @@ -444,6 +552,9 @@ export class ChatToolInvocationPart extends Disposable implements IChatContentPa } hasSameContent(other: IChatRendererContent, followingContent: IChatRendererContent[], element: ChatTreeItem): boolean { + if (this.imageGenerationProgressSuppressed !== this.shouldSuppressImageGenerationProgress()) { + return false; + } if ((other.kind === 'toolInvocation' || other.kind === 'toolInvocationSerialized') && other.toolSpecificData?.kind === 'subagent' && !other.subAgentInvocationId) { @@ -454,7 +565,17 @@ export class ChatToolInvocationPart extends Disposable implements IChatContentPa return false; } if ((other.kind === 'toolInvocation' || other.kind === 'toolInvocationSerialized') - && this.renderedGeneratedImageResult !== shouldRenderGeneratedImageResult(other.toolSpecificData?.kind, isResponseVM(element) && element.isComplete)) { + && this.renderedGeneratedImageResult !== shouldRenderGeneratedImageResult(other.toolSpecificData?.kind, IChatToolInvocation.isComplete(other))) { + return false; + } + if ((other.kind === 'toolInvocation' || other.kind === 'toolInvocationSerialized') + && !other.subAgentInvocationId + && this.renderedGeneratedImageResult + && this.renderedLastGeneratedImageToolCallId !== (getLastGeneratedImageToolCallId(followingContent) ?? other.toolCallId)) { + return false; + } + if (this.renderedGeneratedImageResult && this.renderedLastGeneratedImageToolCallId === this.toolInvocation.toolCallId && isResponseVM(element) + && !equals(this.renderedGeneratedImageResults, getGeneratedImageResultSnapshot(element.response.value), (a, b) => a.toolCallId === b.toolCallId && a.details === b.details)) { return false; } return (other.kind === 'toolInvocation' || other.kind === 'toolInvocationSerialized') && this.toolInvocation.toolCallId === other.toolCallId; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolPartUtilities.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolPartUtilities.ts index 48d55963a69b44..95dff5b93f5325 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolPartUtilities.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolPartUtilities.ts @@ -6,11 +6,52 @@ import { createMarkdownCommandLink, IMarkdownString, MarkdownString } from '../../../../../../../base/common/htmlContent.js'; import { Codicon } from '../../../../../../../base/common/codicons.js'; import { ThemeIcon } from '../../../../../../../base/common/themables.js'; +import { IReader } from '../../../../../../../base/common/observable.js'; import { localize } from '../../../../../../../nls.js'; import { ConfirmedReason, IChatToolInvocation, IChatToolInvocationSerialized, isLegacyChatTerminalToolInvocationData, ToolConfirmKind } from '../../../../common/chatService/chatService.js'; import { isToolResultInputOutputDetails, ToolDataSource } from '../../../../common/tools/languageModelToolsService.js'; +export function isImageGenerationToolInvocation(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized): boolean { + return toolInvocation.toolSpecificData?.kind === 'generatedImage' + || (toolInvocation.toolSpecificData?.kind === 'input' && !!toolInvocation.toolSpecificData.imageGeneration) + || toolInvocation.toolId === 'image_gen.imagegen' + || toolInvocation.toolId === 'image_generation'; +} + +export function isImageGenerationToolInProgress(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized, state?: IChatToolInvocation.State): boolean { + if (toolInvocation.kind !== 'toolInvocation' || !isImageGenerationToolInvocation(toolInvocation) || IChatToolInvocation.isEffectivelyHidden(toolInvocation)) { + return false; + } + const current = state ?? toolInvocation.state.get(); + return current.type === IChatToolInvocation.StateKind.Streaming || current.type === IChatToolInvocation.StateKind.Executing; +} + +export function getImageGenerationInvocationMessage(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized, reader?: IReader): string { + if (toolInvocation.kind === 'toolInvocation') { + toolInvocation.state.read(reader); + } + const model = toolInvocation.toolSpecificData?.kind === 'input' + ? toolInvocation.toolSpecificData.imageGeneration?.requestedModel + : undefined; + return model + ? localize('imageGeneration.usingModel', "Using {0} to generate an image", model.name ?? model.id) + : localize('imageGeneration.progress', "Generating image"); +} + +export function createImageGenerationLabel(message: string): MarkdownString { + const label = new MarkdownString().appendText(message); + // Character references prevent GFM autolinks without double-escaping Markdown syntax. + label.value = label.value.replace(/[.:@]/g, character => `&#${character.charCodeAt(0)};`); + return label; +} + export function hasToolInvocationError(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized): boolean { + if (isImageGenerationToolInvocation(toolInvocation)) { + const confirmation = IChatToolInvocation.executionConfirmedOrDenied(toolInvocation); + if (confirmation?.type === ToolConfirmKind.Denied || confirmation?.type === ToolConfirmKind.Skipped) { + return false; + } + } const resultDetails = IChatToolInvocation.resultDetails(toolInvocation); if (IChatToolInvocation.resultError(toolInvocation) || (isToolResultInputOutputDetails(resultDetails) && resultDetails.isError)) { return true; @@ -32,6 +73,7 @@ export function isActiveBackgroundTerminalToolInvocation(toolInvocation: IChatTo && terminal.terminalCommandState?.exitCode === undefined && (terminal.isBackground === true || terminal.didContinueInBackground === true); } + export function isMcpToolInvocation(toolInvocation: Pick): boolean { return toolInvocation.source?.type === 'mcp' || toolInvocation.toolId.toLowerCase().includes('mcp'); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 836c890001d4ed..db17302ef58cc6 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -134,7 +134,7 @@ import { ChatPendingDragController } from './chatPendingDragAndDrop.js'; import { HookType } from '../../common/promptSyntax/hookTypes.js'; import { IWorkbenchEnvironmentService } from '../../../../services/environment/common/environmentService.js'; import { AccessibilityWorkbenchSettingId } from '../../../accessibility/browser/accessibilityConfiguration.js'; -import { isActiveBackgroundTerminalToolInvocation, isAskQuestionsToolInvocation, isCarouselToolConfirmation, isMcpToolInvocation } from './chatContentParts/toolInvocationParts/chatToolPartUtilities.js'; +import { isActiveBackgroundTerminalToolInvocation, isAskQuestionsToolInvocation, isCarouselToolConfirmation, isImageGenerationToolInProgress, isImageGenerationToolInvocation, isMcpToolInvocation } from './chatContentParts/toolInvocationParts/chatToolPartUtilities.js'; import { isToolResultInputOutputDetails } from '../../common/tools/languageModelToolsService.js'; import { isAgentHostTarget } from '../agentSessions/agentSessions.js'; @@ -288,9 +288,9 @@ export function getFinalResponseStartIndex(content: ReadonlyArray): IChatRendererContent[] { - const outcomeTools = content.filter(isResponseOutcomeTool); + const outcomeTools = content.filter(isSessionCreatedOutcomeTool); if (outcomeTools.length === 0) { return [...content]; } @@ -368,7 +368,7 @@ export function moveResponseOutcomeToolsAfterFinalResponse(content: ReadonlyArra return [...content]; } - const reordered = content.filter(part => !isResponseOutcomeTool(part)); + const reordered = content.filter(part => !isSessionCreatedOutcomeTool(part)); let insertionIndex = finalResponseStartIndex; while (reordered[insertionIndex]?.kind === 'markdownContent') { insertionIndex++; @@ -465,7 +465,7 @@ export function formatResponseTokenStats(modelTotals: readonly IChatUsageModelTo export function shouldCollapseCompletedResponsePart(part: IChatRendererContent): boolean { return (part.kind !== 'toolInvocation' && part.kind !== 'toolInvocationSerialized') - || (!toolInvocationHasMcpAppData(part) && !(isParentSubagentTool(part) && isActiveSubagentToolInvocation(part))); + || (!isImageGenerationToolInvocation(part) && !toolInvocationHasMcpAppData(part) && !(isParentSubagentTool(part) && isActiveSubagentToolInvocation(part))); } export function getCompletedResponseCollapseEndIndex(content: ReadonlyArray, finalResponseStartIndex: number): number { @@ -648,16 +648,6 @@ function toolInvocationHasMcpAppData(toolInvocation: IChatToolInvocation | IChat return toolInvocation.toolSpecificData?.kind === 'input' && !!toolInvocation.toolSpecificData.mcpAppData; } -function isGeneratedImageResultOwner(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized, content: ReadonlyArray): boolean { - for (let index = content.length - 1; index >= 0; index--) { - const part = content[index]; - if ((part.kind === 'toolInvocation' || part.kind === 'toolInvocationSerialized') && part.toolSpecificData?.kind === 'generatedImage') { - return part.toolCallId === toolInvocation.toolCallId; - } - } - return false; -} - const forceVerboseLayoutTracing = false // || Boolean("TRUE") // causes a linter warning so that it cannot be pushed ; @@ -2275,6 +2265,14 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer(ChatConfiguration.PersistentProgressVerbosity) !== ChatProgressVerbosity.Verbose && this.shouldGroupToolInvocation(contentToRender, i, element); - if (promotesSubagent || groupsStandaloneTool || !renderedPart || !renderedPart.hasSameContent(content, contentToRender.slice(i + 1), element)) { + if (promotesStandaloneTool || groupsStandaloneTool || !renderedPart || !renderedPart.hasSameContent(content, contentToRender.slice(i + 1), element)) { diff.push(content); } else { // null -> no change @@ -4087,11 +4085,8 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer - (other.kind === 'toolInvocation' || other.kind === 'toolInvocationSerialized') - && other.toolCallId === toolInvocation.toolCallId); - } - const codeBlockStartIndex = context.codeBlockStartIndex; // Subagent tools are grouped into their own block, so unlike a tool that joins the parent's flow @@ -4793,10 +4777,14 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer this.fireItemHeightChange(templateData))); if (context.suppressProgressShimmer && toolInvocation.kind === 'toolInvocation') { let wasPending = false; + let wasGeneratingImage = false; lazilyCreatedPart.addDisposable(autorun(reader => { - const isPending = isBlockingToolState(toolInvocation.state.read(reader).type); - if (isPending !== wasPending) { + const state = toolInvocation.state.read(reader); + const isPending = isBlockingToolState(state.type); + const isGeneratingImage = isImageGenerationToolInProgress(toolInvocation, state); + if (isPending !== wasPending || isGeneratingImage !== wasGeneratingImage) { wasPending = isPending; + wasGeneratingImage = isGeneratingImage; this.updateWorkingProgress(templateData); } })); @@ -5868,7 +5856,7 @@ export function endsWithCompletedQuestionInteraction(parts: readonly IChatRender && IChatToolInvocation.isComplete(lastPart); } -export type PersistentProgressState = 'active' | 'question' | 'confirmation' | 'planReview' | 'authentication'; +export type PersistentProgressState = 'active' | 'question' | 'confirmation' | 'planReview' | 'authentication' | 'imageGeneration'; /** Tool states that block the response on the user rather than on the model. */ export function isBlockingToolState(state: IChatToolInvocation.StateKind): boolean { @@ -5909,6 +5897,9 @@ export function getPersistentProgressState(parts: readonly IChatRendererContent[ || parts.some(part => part.kind === 'toolInvocation' && part.presentation !== 'hidden' && part.state.get().type === IChatToolInvocation.StateKind.WaitingForAuthentication)) { return 'authentication'; } + if (getWorkingProgressRelevantParts(parts).some(part => part.kind === 'toolInvocation' && isImageGenerationToolInProgress(part))) { + return 'imageGeneration'; + } return 'active'; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts index 8883aad7d8aac3..9fc95eeb9f73cc 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts @@ -1082,6 +1082,10 @@ export class ChatListWidget extends Disposable { */ private _updateElementHeight(element: ChatTreeItem, height?: number): void { if (this._tree.hasElement(element) && this._visible) { + // A refresh must reuse the resized height, not the measurement taken before image loading. + if (height !== undefined) { + this._delegate.setDynamicHeight(element, height); + } const userToggleResizeTracker = this._userToggleResizeTrackers.get(element); if (userToggleResizeTracker) { const scrollTop = this._tree.scrollTop; 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 8c7415abc021bc..bc58d27dc7d152 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -941,6 +941,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private readonly _currentSessionTypeObservable = observableValue(this, undefined); private get _currentSessionType(): string | undefined { return this._currentSessionTypeObservable.get(); } private set _currentSessionType(value: string | undefined) { this._currentSessionTypeObservable.set(value, undefined); } + + /** The session type (harness) whose models this input selects from. */ + get modelTargetSessionType(): string | undefined { return this._currentSessionType ?? this.getCurrentSessionType(); } private readonly _currentSessionResourceObservable = observableValue(this, undefined); private readonly _currentSessionModelObservable = observableValue(this, undefined); private readonly _deferredNotificationsEnabled = observableValue(this, true); @@ -1447,6 +1450,11 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.setCurrentLanguageModel(pinnedModels[nextIndex], true); } + /** Exact desktop picker owned by this input; combined phone sheets have no search contract. */ + public getModelPickerControl(): ReturnType { + return this.chatPhoneInputPresenter.enabled.get() ? undefined : this.modelWidget?.getModelPickerControl(); + } + public openModelPicker(): void { if (this.chatPhoneInputPresenter.enabled.get()) { this._showCombinedPhonePickerSheet(); @@ -1494,6 +1502,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge }, getModels: () => this.getModels(), getProvider: () => getAgentHostProviderForTelemetry(this.getCurrentSessionType(), this.chatSessionsService), + getSessionType: () => this.modelTargetSessionType, isCacheWarm: () => (this._widget?.viewModel?.model.getRequests().length ?? 0) > 0, getPresentationOptions: () => this._getModelPickerPresentationOptions(), modelConfiguration: this._modelConfigStore, @@ -3719,6 +3728,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge kind: 'last', minItems: 2, actionMinWidth: 48, + // Let the pickers yield to the execute toolbar so that a narrow + // input never pushes the send button out of view. + reserveMinWidth: false, getActionMinWidth: getInputActionMinWidth, allowOverflow: () => this._inputPickerResponsiveLayout?.areAllItemsCompact() === true, getOverflowAction: (action, getAnchor) => getOverflowAction(action, inputToolbarMenu, inputOverflowPickerHandlers, getAnchor, toolbarsContainer), diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/agentHostInputCompletions.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/agentHostInputCompletions.ts index 310ddea494e03a..2f95a91ea2ec4e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/agentHostInputCompletions.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/agentHostInputCompletions.ts @@ -30,6 +30,7 @@ import { getChatSessionType } from '../../../../common/model/chatUri.js'; import { IChatWidget, IChatWidgetService } from '../../../chat.js'; import { applyAgentHostCompletionAction, isPolicyBlockedCompletionAction } from '../../../agentHostCompletionAction.js'; import { applyAgentHostSessionConfigChange } from '../../../agentSessions/agentHost/applyAgentHostSessionConfig.js'; +import { IAgentHostConnectionsService } from '../../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { IAgentHostSessionWorkingDirectoryResolver } from '../../../agentSessions/agentHost/agentHostSessionWorkingDirectoryResolver.js'; import { IAgentHostUntitledProvisionalSessionService } from '../../../agentSessions/agentHost/agentHostUntitledProvisionalSessionService.js'; import { AgentHostInputCompletionsBase } from './agentHostInputCompletionsBase.js'; @@ -98,6 +99,7 @@ export class AgentHostInputCompletions extends AgentHostInputCompletionsBase void; readonly select: (identifier: string) => boolean } | undefined { + const element = this._pickerWidget.nameButton; + return element && this._pickerWidget.canOpenWithFilter() ? { + element, + open: options => this.show(undefined, options), + select: identifier => { + const model = this.delegate.getModels().find(model => model.identifier === identifier && model.metadata.isUserSelectable !== false); + if (!model || !this._pickerWidget.canOpenWithFilter()) { + return false; + } + this.delegate.setModel(model); + return true; + }, + } : undefined; } public setEnabled(enabled: boolean): void { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItems.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItems.ts index 59f06cf8beb1d7..fbfb36c1fa9186 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItems.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItems.ts @@ -66,7 +66,7 @@ export function shouldShowManageModelsAction(chatEntitlementService: IChatEntitl chatEntitlementService.isInternal; } -export function createManageModelsAction(commandService: ICommandService): IActionWidgetDropdownAction { +export function createManageModelsAction(commandService: ICommandService, sessionType?: string): IActionWidgetDropdownAction { return { id: 'manageModels', enabled: true, @@ -74,7 +74,7 @@ export function createManageModelsAction(commandService: ICommandService): IActi class: ThemeIcon.asClassName(Codicon.gear), tooltip: localize('chat.manageModels.tooltip', "Manage Language Models"), label: localize('chat.manageModels', "Manage Models..."), - run: () => { commandService.executeCommand(MANAGE_CHAT_COMMAND_ID); }, + run: () => { commandService.executeCommand(MANAGE_CHAT_COMMAND_ID, undefined, sessionType); }, }; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerTabbedWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerTabbedWidget.ts index ccaa24afc64ec9..649ea87499c0ee 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerTabbedWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerTabbedWidget.ts @@ -7,6 +7,7 @@ import * as dom from '../../../../../../../base/browser/dom.js'; import { Button } from '../../../../../../../base/browser/ui/button/button.js'; import { status } from '../../../../../../../base/browser/ui/aria/aria.js'; import { SelectBox } from '../../../../../../../base/browser/ui/selectBox/selectBox.js'; +import type { IModelPickerOpenOptions } from './modelPickerWidget.js'; import { ActionBar } from '../../../../../../../base/browser/ui/actionbar/actionbar.js'; import { IAction, toAction } from '../../../../../../../base/common/actions.js'; import { IStringDictionary } from '../../../../../../../base/common/collections.js'; @@ -190,7 +191,12 @@ export class TabbedModelPicker extends Disposable { super.dispose(); } - show(anchor: HTMLElement, context: ITabbedModelPickerContext, detailsModelId?: string, focusConfiguration = false, contextViewLayer?: number): void { + openWithFilter(options: IModelPickerOpenOptions): void { + this._searchVisible = true; + this._showCurrent(options.initialFilterValue, options.initialFocusItemId); + } + + show(anchor: HTMLElement, context: ITabbedModelPickerContext, detailsModelId?: string, focusConfiguration = false, contextViewLayer?: number, options?: IModelPickerOpenOptions): void { if (!this._widget.isVisible) { this._activeDestination = undefined; if (context.workflow?.available.get() && context.workflow.summary.get()) { @@ -205,7 +211,10 @@ export class TabbedModelPicker extends Disposable { this._configurationListener.value = context.configurationAccess.onDidChange?.(() => this.refresh()); this._contextViewLayer = contextViewLayer; this._rememberSelection(pickerContext.selectedModelId); - this._showCurrent(); + if (options?.initialFilterValue !== undefined) { + this._searchVisible = true; + } + this._showCurrent(options?.initialFilterValue, options?.initialFocusItemId); const detailsModel = pickerContext.models.find(model => model.identifier === detailsModelId); if (detailsModel && !isAutoModel(detailsModel) && !isHydraFusionModel(detailsModel)) { this._showModelDetails(detailsModel, focusConfiguration); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts index 5c35653aa7b81f..cf8b71a578e52c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts @@ -7,6 +7,7 @@ import './media/modelPicker.css'; import * as dom from '../../../../../../../base/browser/dom.js'; import { StandardKeyboardEvent } from '../../../../../../../base/browser/keyboardEvent.js'; +import { EventType as TouchEventType, Gesture } from '../../../../../../../base/browser/touch.js'; import { renderIcon } from '../../../../../../../base/browser/ui/iconLabel/iconLabels.js'; import { getBaseLayerHoverDelegate } from '../../../../../../../base/browser/ui/hover/hoverDelegate2.js'; import { getDefaultHoverDelegate } from '../../../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; @@ -51,6 +52,12 @@ import { whenModelConfigValuesSaved } from './modelPickerModelConfig.js'; import { IModelPickerProviderPlaceholder } from './modelPickerTabs.js'; import { getModelPickerUnavailableReason, isAutoModel, isHydraFusionModel, isHydraFusionUpgradeOnly, ModelPickerUnavailableReason, modelPickerRequiresSetup, shouldShowCacheBreakHint as computeShouldShowCacheBreakHint } from './modelPickerPresentation.js'; +/** Trusted caller options for opening a searchable picker without toggling it closed. */ +export interface IModelPickerOpenOptions { + readonly initialFilterValue?: string; + readonly initialFocusItemId?: string; +} + const CACHE_BREAK_HINT_DISMISSED_STORAGE_KEY = 'chat.cacheBreakHintDismissed'; /** Opt-in setting for the tabbed model picker and its model details page. */ @@ -433,16 +440,24 @@ export class ModelPickerWidget extends Disposable { } /** - * Registers mouse-down and Enter/Space key handlers on a button element. + * Registers mouse-down, touch tap, and Enter/Space key handlers on a button element. */ private _registerButtonAction(element: HTMLElement, action: (fromKeyboard: boolean) => void): void { - this._register(dom.addDisposableGenericMouseDownListener(element, e => { + this._register(dom.addDisposableListener(element, dom.EventType.MOUSE_DOWN, e => { if (e.button !== 0) { return; } dom.EventHelper.stop(e, true); action(false); })); + // Touch and pen need an explicit tap handler: an ancestor `Gesture` target + // (e.g. the view pane container) cancels the touch, which suppresses the + // compatibility `mousedown` the browser would otherwise synthesize. + this._register(Gesture.addTarget(element)); + this._register(dom.addDisposableListener(element, TouchEventType.Tap, e => { + dom.EventHelper.stop(e, true); + action(false); + })); this._register(dom.addDisposableListener(element, dom.EventType.KEY_DOWN, (e) => { const event = new StandardKeyboardEvent(e); if (event.equals(KeyCode.Enter) || event.equals(KeyCode.Space)) { @@ -513,7 +528,7 @@ export class ModelPickerWidget extends Disposable { }]; } - private _showTabbedPicker(anchor: HTMLElement, context: ITabbedModelPickerContext, telemetrySession: ModelPickerTelemetrySession, detailsModelId?: string, focusConfiguration = false): void { + private _showTabbedPicker(anchor: HTMLElement, context: ITabbedModelPickerContext, telemetrySession: ModelPickerTelemetrySession, detailsModelId?: string, focusConfiguration = false, options?: IModelPickerOpenOptions): void { const picker = this._tabbedPicker.value ?? (this._tabbedPicker.value = this._instantiationService.createInstance(TabbedModelPicker)); const previouslyFocusedElement = dom.getActiveElement(); const trigger = detailsModelId ? this._configButton : this._nameButton; @@ -535,27 +550,41 @@ export class ModelPickerWidget extends Disposable { this._configButton?.setAttribute('aria-expanded', 'true'); } this._domNode?.classList.add('model-picker-active'); - picker.show(anchor, context, detailsModelId, focusConfiguration, this._contextViewLayer); + picker.show(anchor, context, detailsModelId, focusConfiguration, this._contextViewLayer, options); + } + + canOpenWithFilter(): boolean { + return !!this._domNode?.isConnected && !this._domNode.classList.contains('disabled') + && !this.isRestrictedMode() && !this.isSetupRequired(); } - show(anchor?: HTMLElement, showDetails = false, focusConfiguration = false, trigger: IModelPickerOpenTrigger = { entryPoint: 'command', inputMethod: 'unknown' }): void { - this._show(anchor, showDetails, focusConfiguration, trigger); + show(anchor?: HTMLElement, showDetails = false, focusConfiguration = false, trigger: IModelPickerOpenTrigger = { entryPoint: 'command', inputMethod: 'unknown' }, options?: IModelPickerOpenOptions): void { + this._show(anchor, showDetails, focusConfiguration, trigger, options); } /** * @param telemetry How the picker was opened, or the session of a flat picker * that pinning re-shows in place, so it keeps reporting as one interaction. */ - private _show(anchor: HTMLElement | undefined, showDetails: boolean, focusConfiguration: boolean, telemetry: IModelPickerOpenTrigger | ModelPickerTelemetrySession): void { + private _show(anchor: HTMLElement | undefined, showDetails: boolean, focusConfiguration: boolean, telemetry: IModelPickerOpenTrigger | ModelPickerTelemetrySession, options?: IModelPickerOpenOptions): void { const anchorElement = anchor ?? this._domNode; if (!anchorElement || this._domNode?.classList.contains('disabled')) { return; } - if (this._tabbedPicker.value?.isVisible) { + if (options && this._tabbedPicker.value?.isVisible) { + this._tabbedPicker.value.openWithFilter(options); + return; + } + if (options && this._nameButton?.getAttribute('aria-expanded') === 'true') { + this._actionWidgetService.setFilter(options.initialFilterValue ?? '', options.initialFocusItemId); + return; + } + + if (!options && this._tabbedPicker.value?.isVisible) { this._tabbedPicker.value.hide(); return; } - if (this._nameButton?.getAttribute('aria-expanded') === 'true') { + if (!options && this._nameButton?.getAttribute('aria-expanded') === 'true') { this._actionWidgetService.hide(true); return; } @@ -585,7 +614,7 @@ export class ModelPickerWidget extends Disposable { const manifest = this._languageModelsService.getModelsControlManifest(); const controlModelsForTier: IStringDictionary = getModelPickerControlModels(manifest, this._entitlementService.entitlement, models); const canShowManageModelsAction = presentation.showManageModelsAction && shouldShowManageModelsAction(this._entitlementService); - const manageModelsAction = canShowManageModelsAction ? createManageModelsAction(this._commandService) : undefined; + const manageModelsAction = canShowManageModelsAction ? createManageModelsAction(this._commandService, this._delegate.getSessionType?.()) : undefined; const logModelPickerInteraction = (interaction: ChatModelPickerInteraction) => { this._telemetryService.publicLog2('chat.modelPickerInteraction', { interaction }); }; @@ -655,7 +684,7 @@ export class ModelPickerWidget extends Disposable { link: this.getCacheBreakLearnMoreLink(), dismiss: () => this.dismissCacheBreakHint(), } : undefined, - }, telemetrySession, showDetails && this._selectedModel && !isAutoModel(this._selectedModel) && !isHydraFusionModel(this._selectedModel) ? this._selectedModel.identifier : undefined, focusConfiguration); + }, telemetrySession, showDetails && this._selectedModel && !isAutoModel(this._selectedModel) && !isHydraFusionModel(this._selectedModel) ? this._selectedModel.identifier : undefined, focusConfiguration, options); return; } @@ -722,6 +751,8 @@ export class ModelPickerWidget extends Disposable { headerLink: showCacheBreakHint ? this.getCacheBreakLearnMoreLink() : undefined, headerDismiss: showCacheBreakHint ? () => this.dismissCacheBreakHint() : undefined, showFilter: !unavailable, + initialFilterValue: options?.initialFilterValue, + initialFocusItemId: options?.initialFocusItemId, filterPlaceholder: localize('chat.modelPicker.search', "Search models"), focusFilterOnOpen: true, filterAsCombobox: !unavailable, diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index e7b2942b8a9cbb..ed52c2919a81ac 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -3863,6 +3863,16 @@ have to be updated for changes to the rules above, or to support more deeply nes display: inline-block; } +.chat-attached-context-attachment .chat-attached-context-image-status { + color: var(--vscode-descriptionForeground); + white-space: normal; + overflow-wrap: anywhere; +} + +.chat-attached-context-attachment.image-load-error .chat-attached-context-image-status { + color: var(--vscode-errorForeground); +} + .chat-attached-context-attachment.show-file-icons.warning .chat-attached-context-custom-text { color: var(--vscode-notificationsWarningIcon-foreground); text-decoration: line-through; @@ -3923,39 +3933,58 @@ have to be updated for changes to the rules above, or to support more deeply nes } .chat-generated-image-result { - margin: 8px 0 4px; + --chat-generated-image-max-height: min(60vh, 400px); + margin: var(--vscode-spacing-size80) 0 var(--vscode-spacing-size40); + + .chat-collapsible-io-resource-group { + width: fit-content; + max-width: 100%; + gap: var(--vscode-spacing-size80); + } + + .chat-collapsible-io-resource-actions { + flex-shrink: 0; + } .chat-collapsible-io-resource-items, .chat-attached-context { min-width: 0; - width: 100%; + max-width: 512px; } .chat-collapsible-io-resource-items .chat-attached-context-attachment.image-attachment { - width: min(100%, 512px); + width: fit-content; height: auto; background: transparent; + + &[aria-busy="true"]:not(:focus-visible), + &.image-load-error:not(:focus-visible) { + border-color: transparent; + } } .chat-collapsible-io-resource-items .chat-attached-context-attachment.image-attachment .chat-attached-context-pill { + min-width: 0; + width: auto; height: auto; } .chat-collapsible-io-resource-items .chat-attached-context-attachment.image-attachment .chat-attached-context-pill-image { display: block; + width: auto; + max-width: 100%; height: auto; - max-height: min(60vh, 640px); + max-height: var(--chat-generated-image-max-height); object-fit: contain; } &.multiple .chat-attached-context { - max-width: 512px; + display: grid; + grid-template-columns: repeat(2, minmax(0, auto)); } &.multiple .chat-collapsible-io-resource-items .chat-attached-context-attachment.image-attachment { - flex: 1 1 calc(50% - 4px); - width: calc(50% - 4px); - max-width: 252px; + max-width: min(100%, 252px); } } diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index 907fc0350e25a3..d37ed31c71a369 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -850,6 +850,17 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { private sessionsControl: AgentSessionsControl | undefined; get agentSessionsControl(): AgentSessionsControl | undefined { return this.sessionsControl; } + private sessionsListRevealCount = 0; + + /** Shows the Sessions list for a navigation flow without replacing the current chat or saved layout. */ + revealSessionsList(): IDisposable { + this.sessionsListRevealCount++; + this.refreshSessionsControlVisibility(); + return toDisposable(() => { + this.sessionsListRevealCount--; + if (!this._store.isDisposed) { this.refreshSessionsControlVisibility(); } + }); + } private sessionsViewerVisible: boolean; private sessionsViewerOrientation = AgentSessionsViewerOrientation.Stacked; @@ -971,7 +982,9 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { } let newSessionsContainerVisible: boolean; - if (!this.configurationService.getValue(ChatConfiguration.ChatViewSessionsEnabled)) { + if (this.sessionsListRevealCount > 0) { + newSessionsContainerVisible = true; + } else if (!this.configurationService.getValue(ChatConfiguration.ChatViewSessionsEnabled)) { newSessionsContainerVisible = false; // disabled in settings } else { diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 89f8f203a1e934..b06d88210155ea 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -848,6 +848,8 @@ export interface IChatToolInputInvocationData { kind: 'input'; // eslint-disable-next-line @typescript-eslint/no-explicit-any rawInput: any; + /** Image-generation input and its independently selected image model. */ + imageGeneration?: { readonly requestedModel?: { readonly id: string; readonly name?: string } }; /** Optional MCP App UI metadata for rendering during and after tool execution */ mcpAppData?: ChatMcpAppData; /** @@ -996,6 +998,7 @@ export namespace IChatToolInvocation { source?: ToolConfirmationSource; /** Optional message explaining why the tool was cancelled (e.g., from hook denial) */ reasonMessage?: string | IMarkdownString; + resultDetails?: IToolResult['toolResultDetails']; } export type State = @@ -1105,7 +1108,7 @@ export namespace IChatToolInvocation { } const state = invocation.state.read(reader); - if (state.type === StateKind.Completed || state.type === StateKind.WaitingForPostApproval) { + if (state.type === StateKind.Completed || state.type === StateKind.WaitingForPostApproval || state.type === StateKind.Cancelled) { return state.resultDetails; } diff --git a/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatToolInvocation.ts b/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatToolInvocation.ts index e51ff717b8a7f6..173b2e01186b55 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatToolInvocation.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatToolInvocation.ts @@ -218,6 +218,15 @@ export class ChatToolInvocation implements IChatToolInvocation { return false; // Only cancel from streaming state } + this.didCancelTool(reason, reasonMessage); + return true; + } + + /** Records provider cancellation without turning an executed tool into a failed completion. */ + public didCancelTool(reason: ToolDeniedReason, reasonMessage?: string | IMarkdownString, resultDetails?: IToolResult['toolResultDetails']): void { + if (IChatToolInvocation.isComplete(this)) { + return; + } this._state.set({ type: IChatToolInvocation.StateKind.Cancelled, reason: reason.type, @@ -225,8 +234,8 @@ export class ChatToolInvocation implements IChatToolInvocation { reasonMessage: reasonMessage, parameters: this.parameters, confirmationMessages: this.confirmationMessages, + ...(resultDetails ? { resultDetails } : {}), }, undefined); - return true; } /** diff --git a/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts b/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts index dc97eb9016ba8a..29a469da45b977 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts @@ -165,6 +165,8 @@ export interface IChatWorkingProgress { progressStep?: number; /** Whether prolonged response inactivity should replace the current phrase with the delayed-progress message. */ showDelayedProgressMessage?: boolean; + /** Rotates painting-themed phrases while an image tool is running. */ + imageGeneration?: boolean; } diff --git a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts index b2435d99708136..585a0626ce9461 100644 --- a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts @@ -14,11 +14,49 @@ import { AGENT_SESSION_RENAME_ACTION_ID } from '../../../browser/agentSessions/a suite('Chat Accessibility Help', () => { ensureNoDisposablesAreLeakedInTestSuite(); + for (const type of ['panelChat', 'agentView', 'editsView', 'inlineChat', 'quickChat'] as const) { + test(`only describes visible Codex continuation UI (${type})`, () => { + const keybindings = new MockKeybindingService(); + const hidden = getAccessibilityHelpText(type, keybindings, false); + const shown = getAccessibilityHelpText(type, keybindings, false, false, false, false, false, ChatSessionArchiveActionWording.Archive, true); + assert.deepStrictEqual({ + hidden: hidden.includes('A ChatGPT limit suggestion'), + shown: shown.includes('A ChatGPT limit suggestion'), + }, { hidden: false, shown: type !== 'inlineChat' && type !== 'quickChat' }); + }); + } + test('documents the sandbox policy command and report link', () => { const help = getAccessibilityHelpText('agentView', new MockKeybindingService(), true); assert.ok(help.includes('use /sandbox policy to view the effective sandbox policy. In the response, use Tab to focus Open Sandbox Policy and Enter to open the formatted report.')); }); + test('documents generated image previews and saving', () => { + const help = getAccessibilityHelpText('agentView', new MockKeybindingService(), true); + assert.deepStrictEqual({ + standalone: help.includes('Image generation appears separately from thinking and tool-call groups'), + harnesses: help.includes('Copilot and Codex agent sessions share this presentation'), + position: help.includes('Completed steps collapse only before the first image-generation tool, keeping image tools in their original position'), + placeholder: help.includes('A compact decorative band of shifting glyphs remains visible below the dropdown'), + overlapping: help.includes('Overlapping image-generation attempts each keep their own input dropdown and share one animation'), + reducedMotion: help.includes('does not indicate a percentage complete and stays still when reduced motion is enabled or a high contrast theme is active'), + reveal: help.includes('The reveal is skipped in those modes and when reopening a chat'), + dropdown: help.includes('While generation runs, expand the tool dropdown to inspect the submitted prompt'), + expand: help.includes('Tab to focus the dropdown and Enter or Space to expand or collapse it'), + progress: help.includes('Only the persistent progress line rotates through painting-themed phrases, without repeatedly announcing them'), + imageModel: help.includes('Its title says Using the image model to generate an image when the model is available, and does not shimmer'), + imageAndDropdown: help.includes('Successful generation shows the Generated image dropdown, including the image model when available, with a single image below it even when collapsed'), + failure: help.includes('Generated image failed row can be expanded to inspect the error'), + restoredState: help.includes('Completed and failed image tools keep their final status when you reopen the chat'), + cancelledState: help.includes('Stopping image generation shows Image generation cancelled, not a generation error'), + previews: help.includes('Image previews start loading as soon as generation succeeds, even if the response is still in progress'), + loading: help.includes('A preview is marked as busy while its bytes load'), + loadFailure: help.includes('Unable to load image indicates a problem loading the preview, not a failed generation'), + keyboard: help.includes('Tab or Shift+Tab to focus an image and Enter to open it'), + save: help.includes('Use the Save action to the right of the image to save generated images'), + }, { standalone: true, harnesses: true, position: true, placeholder: true, overlapping: true, reducedMotion: true, reveal: true, dropdown: true, expand: true, progress: true, imageModel: true, imageAndDropdown: true, failure: true, restoredState: true, cancelledState: true, previews: true, loading: true, loadFailure: true, keyboard: true, save: true }); + }); + for (const type of ['panelChat', 'editsView', 'agentView'] as const) { test(`documents skipping MCP startup only on supported surfaces (${type})`, () => { const help = getAccessibilityHelpText(type, new MockKeybindingService(), false); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/openSessionLinkOpener.contribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/openSessionLinkOpener.contribution.test.ts index 1c07e9cb077a49..4197998b2bebd8 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/openSessionLinkOpener.contribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHost/openSessionLinkOpener.contribution.test.ts @@ -4,7 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; -import { Event } from '../../../../../../../base/common/event.js'; +import { DeferredPromise } from '../../../../../../../base/common/async.js'; +import { Emitter, Event } from '../../../../../../../base/common/event.js'; import { Disposable, IDisposable } from '../../../../../../../base/common/lifecycle.js'; import { waitForState } from '../../../../../../../base/common/observable.js'; import { URI } from '../../../../../../../base/common/uri.js'; @@ -12,6 +13,8 @@ import { mock } from '../../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { NullLogService } from '../../../../../../../platform/log/common/log.js'; import { buildOpenSessionLinkUri } from '../../../../../../../platform/agentHost/common/openSessionLink.js'; +import { IAgentHostConnectionsService } from '../../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { IAgentConnection, IAgentSessionMetadata } from '../../../../../../../platform/agentHost/common/agentService.js'; import { ILinkPresentationProvider, ILinkPresentationProviderRegistration, ILinkPresentationService } from '../../../../../../../platform/dataChannel/common/dataChannel.js'; import { IOpener, IOpenerService } from '../../../../../../../platform/opener/common/opener.js'; import { IPathService } from '../../../../../../services/path/common/pathService.js'; @@ -24,6 +27,114 @@ import { ChatSessionStatus, IChatSessionItem, IChatSessionsService } from '../.. suite('AgentHostOpenSessionLinkOpenerContribution', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); + test('scoped standard links discover only their owning connection despite a colliding ambient ID', async () => { + const backend = URI.parse('ahp-session:/shared'); + const remote = URI.parse('remote-other-host-claude:/shared'); + const local = URI.parse('agent-host-codex:/shared'); + const resources = new Map([[`local:${backend}`, local]]); + const opened: string[] = []; + let listings = 0; + let opener: IOpener | undefined; + const owner = new class extends mock() { + override async listSessions(): Promise { + listings++; + return [{ session: backend, provider: 'claude', startTime: 0, modifiedTime: 0 }]; + } + }(); + const connections = new class extends mock() { + override readonly ambientConnection = new class extends mock() { + override async listSessions(): Promise { throw new Error('Wrong connection'); } + }(); + override getConnectionByAuthority(authority: string): IAgentConnection | undefined { return authority === 'other-host' ? owner : undefined; } + override findSessionResource(resource: URI, authority = 'local'): URI | undefined { return resources.get(`${authority}:${resource}`); } + override registerSessionResource(resource: URI, authority?: string, provider?: string): URI { + assert.deepStrictEqual([authority, provider], ['other-host', 'claude']); + resources.set(`${authority}:${resource}`, remote); + return remote; + } + }(); + store.add(new AgentHostOpenSessionLinkOpenerContribution( + new class extends mock() { + override registerOpener(value: IOpener): IDisposable { opener = value; return Disposable.None; } + }(), + new class extends mock() { + override async openSession(resource: URI): Promise { opened.push(resource.toString()); return {} as IChatWidget; } + }(), + new class extends mock() { override async activateChatSessionItemProvider(): Promise { } }(), + store.add(new ChatRequestOriginService()), + new class extends mock() { override registerLinkPresentationProvider(): IDisposable { return Disposable.None; } }(), + new NullLogService(), + new class extends mock() { override registerProvider(): IDisposable { return Disposable.None; } }(), + new class extends mock() { }(), + connections, + )); + assert.ok(opener); + const result = await opener.open(URI.parse(buildOpenSessionLinkUri(backend, 'peer', undefined, 'other-host'))); + assert.deepStrictEqual({ result, listings, opened }, { result: true, listings: 1, opened: [remote.with({ fragment: 'peer' }).toString()] }); + }); + + test('standard link presentation waits for advertised provider metadata and coalesces the initial listing', async () => { + const backend = URI.parse('ahp-session:/cold-link'); + const client = URI.parse('agent-host-codex:/cold-link'); + const ready = new DeferredPromise(); + const changed = store.add(new Emitter()); + const providers = new Map(); + const resources = new Map(); + let listings = 0; + const connection = new class extends mock() { + override async listSessions(): Promise { listings++; return ready.p; } + }(); + const connections = new class extends mock() { + override readonly ambientConnection = connection; + override readonly onDidChangeSessionResolution = changed.event; + override findSessionResource(resource: URI): URI | undefined { return resources.get(resource.toString()); } + override registerSessionResource(resource: URI, _authority?: string, provider?: string): URI { + assert.strictEqual(provider, 'codex'); + resources.set(resource.toString(), client); + changed.fire(); + return client; + } + }(); + const sessions = new class extends mock() { + override readonly onDidChangeAvailability = Event.None; + override readonly onDidChangeInProgress = Event.None; + override readonly onDidChangeItemsProviders = Event.None; + override readonly onDidChangeSessionItems = Event.None; + override async activateChatSessionItemProvider(): Promise { } + override async *getChatSessionItems(): AsyncIterable<{ chatSessionType: string; items: readonly IChatSessionItem[] }> { + const timing = { created: 1, lastRequestStarted: 1, lastRequestEnded: 1 }; + yield { chatSessionType: client.scheme, items: [{ resource: client, label: 'Root', timing, children: [{ resource: client.with({ fragment: 'peer' }), label: 'Peer', timing }] }] }; + } + }(); + store.add(new AgentHostOpenSessionLinkOpenerContribution( + new class extends mock() { override registerOpener() { return Disposable.None; } }(), + new class extends mock() { }(), + sessions, + store.add(new ChatRequestOriginService()), + new class extends mock() { + override registerLinkPresentationProvider(registration: ILinkPresentationProviderRegistration, provider: ILinkPresentationProvider) { + providers.set(registration.id, provider); + return Disposable.None; + } + }(), + new NullLogService(), + new class extends mock() { override registerProvider() { return Disposable.None; } }(), + new class extends mock() { }(), + connections, + )); + const root = store.add(providers.get('workbench.agentSessionLinkPresentation')!.createLinkPresentationWatcher(URI.parse(buildOpenSessionLinkUri(backend)))); + const peer = store.add(providers.get('workbench.agentChatLinkPresentation')!.createLinkPresentationWatcher(URI.parse(buildOpenSessionLinkUri(backend, 'peer')))); + const initial = [root.presentation.get(), peer.presentation.get(), listings]; + await ready.complete([{ session: backend, provider: 'codex', startTime: 1000, modifiedTime: 2000 }]); + const rootPresentation = await waitForState(root.presentation, value => value?.title === 'Root'); + const peerPresentation = await waitForState(peer.presentation, value => value?.title === 'Peer'); + assert.deepStrictEqual({ + initial, + titles: [rootPresentation?.title, peerPresentation?.title], + listings, + }, { initial: [undefined, undefined, 1], titles: ['Root', 'Peer'], listings: 1 }); + }); + test('opens a delegated request-origin link through the same opener as an agent-host-session:// URI', async () => { let registeredOpener: IOpener | undefined; const openerService = new class extends mock() { @@ -60,6 +171,7 @@ suite('AgentHostOpenSessionLinkOpenerContribution', () => { override registerProvider(): IDisposable { return Disposable.None; } }, new class extends mock() { }, + new class extends mock() { }, )); assert.ok(registeredOpener, 'expected an opener service opener to be registered'); @@ -140,6 +252,7 @@ suite('AgentHostOpenSessionLinkOpenerContribution', () => { override registerProvider(): IDisposable { return Disposable.None; } }, new class extends mock() { }, + new class extends mock() { }, )); assert.ok(registeredOpener); @@ -180,6 +293,7 @@ suite('AgentHostOpenSessionLinkOpenerContribution', () => { override registerProvider(): IDisposable { return Disposable.None; } }, new class extends mock() { }, + new class extends mock() { }, )); const opened = await requestOriginService.open({ diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostByokLmHandler.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostByokLmHandler.test.ts index 330828dc9ae141..c5abedd9fa6e24 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostByokLmHandler.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostByokLmHandler.test.ts @@ -512,6 +512,38 @@ suite('AgentHostByokLmHandler', () => { }); }); + test('forwards tool-result images as ordered data parts of the tool result', async () => { + const service = new TestLanguageModelsService( + new Map([['id', byokModel('acme', 'claude')]]), + () => responseOf([{ type: 'text', value: 'ok' }]), + ); + const handler = createHandler(service); + + await handler.chat({ + vendor: 'acme', + modelId: 'claude', + input: [ + { type: 'function_call', callId: 't1', name: 'view', argumentsJson: '{"path":"/work/a.png"}' }, + { + type: 'function_call_output', callId: 't1', output: 'caption Acaption B', content: [ + { type: 'text', text: 'caption A' }, + { type: 'image', mimeType: 'image/png', data: 'aW1hZ2VB' }, + { type: 'text', text: 'caption B' }, + { type: 'image', mimeType: 'image/jpeg', data: 'aW1hZ2VC' }, + ] + }, + ], + }, CancellationToken.None); + + const toolResult = service.captured?.messages.at(-1)?.content[0]; + assert.deepStrictEqual(toolResult?.type === 'tool_result' && toolResult.value.map(part => part.type === 'data' ? { ...part, data: part.data.toString() } : part), [ + { type: 'text', value: 'caption A' }, + { type: 'data', mimeType: 'image/png', data: 'imageA' }, + { type: 'text', value: 'caption B' }, + { type: 'data', mimeType: 'image/jpeg', data: 'imageB' }, + ]); + }); + test('keeps omitted-file notes distinct when merging adjacent text parts', async () => { const service = new TestLanguageModelsService( new Map([['id', byokModel('acme', 'claude')]]), 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 d9f23010fb2117..18cbfed81f96a4 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 @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import sinon from 'sinon'; import * as dom from '../../../../../../base/browser/dom.js'; import { setARIAContainer } from '../../../../../../base/browser/ui/aria/aria.js'; import { encodeBase64, VSBuffer } from '../../../../../../base/common/buffer.js'; @@ -18,6 +19,8 @@ import { IUriIdentityService } from '../../../../../../platform/uriIdentity/comm import { hasKey } from '../../../../../../base/common/types.js'; import { getSubagentEditorResource } from '../../../browser/widget/chatContentParts/chatSubagentOpenChat.js'; import { URI } from '../../../../../../base/common/uri.js'; +import { IAgentHostConnectionsService } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { AgentHostConnectionsService } from '../../../../../../platform/agentHost/browser/agentHostConnectionsService.js'; import { autorun, constObservable, derived, ISettableObservable, observableValue, type IObservable } from '../../../../../../base/common/observable.js'; import { mock, upcastPartial } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; @@ -101,7 +104,7 @@ import { EditorInput } from '../../../../../common/editor/editorInput.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; import { MockLabelService } from '../../../../../services/label/test/common/mockLabelService.js'; import { IAgentHostFileSystemService } from '../../../../../services/agentHost/common/agentHostFileSystemService.js'; -import { IRemoteAgentHostService } from '../../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IRemoteAgentHostService, NullRemoteAgentHostService } from '../../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; import { IWorkingCopyService } from '../../../../../services/workingCopy/common/workingCopyService.js'; import { IWorkbenchAssignmentService } from '../../../../../services/assignment/common/assignmentService.js'; @@ -235,7 +238,7 @@ class MockAgentHostService extends mock() { /** Declares what the host reported at `initialize`, for version- and scheme-gated behaviour. */ setInitializeResult(result: Partial): void { - this._initializeResult.set({ ...this._initializeResult.get(), ...result } as InitializeResult, undefined); + this._initializeResult.set({ protocolVersion: PROTOCOL_VERSION, serverSeq: 0, snapshots: [], _meta: { 'vscode.agentHost': true }, ...this._initializeResult.get(), ...result }, undefined); } setHostProtocolVersion(protocolVersion: string): void { @@ -311,7 +314,34 @@ class MockAgentHostService extends mock() { public nextResolvedWorkingDirectory?: URI; override async listSessions(): Promise { - return [...this._sessions.values()]; + const sessions = new Map([...this._sessions.values()].map(metadata => [metadata.session.toString(), metadata])); + for (const [resource, state] of this.sessionStates) { + if (sessions.has(resource) || isAhpChatChannel(resource)) { + continue; + } + sessions.set(resource, { + session: URI.parse(resource), + provider: AgentSession.provider(resource) ?? state.provider, + summary: state.title, + startTime: 0, + modifiedTime: 0, + status: state.status, + }); + } + for (const resource of this.failNextSubscriptionFor) { + if (!sessions.has(resource) && !isAhpChatChannel(resource)) { + sessions.set(resource, { session: URI.parse(resource), provider: AgentSession.provider(resource), startTime: 0, modifiedTime: 0 }); + } + } + return [...sessions.values()]; + } + + seedSessionMetadata(session: URI, provider = AgentSession.provider(session)): void { + const rawId = AgentSession.id(session); + if ([...this._sessions.keys(), ...this.sessionStates.keys(), ...this.failNextSubscriptionFor].some(resource => AgentSession.id(resource) === rawId)) { + return; + } + this._sessions.set(session.toString(), { session, provider, startTime: 0, modifiedTime: 0 }); } override async resolveSessionConfig(request: Parameters[0]) { @@ -815,6 +845,12 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv }; instantiationService.stub(IAgentHostService, agentHostService); + instantiationService.stub(IAgentHostConnectionsService, disposables.add(new AgentHostConnectionsService( + agentHostService, + new NullRemoteAgentHostService(), + upcastPartial({ registerPathProvider: () => Disposable.None }), + new NullLogService(), + ))); instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(IProductService, { quality: 'insider' }); instantiationService.stub(ITelemetryService, NullTelemetryService); @@ -1142,7 +1178,7 @@ function createSessionListController(disposables: DisposableStore, instantiation return disposables.add(instantiationService.createInstance(AgentHostSessionListController, sessionType, provider, sessionListStore, description, 'local')); } -function createContribution(disposables: DisposableStore, opts?: { authServiceOverride?: Partial; workingDirectoryResolver?: { resolve(sessionResource: URI): URI | undefined; isNewSession?: (sessionResource: URI) => boolean }; languageModels?: ReadonlyMap; provisionalServiceOverride?: Partial; languageModelToolsServiceOverride?: Partial; configOverrides?: Record; provider?: string; chatSessionsServiceOverride?: Partial; chatDebugServiceOverride?: Partial; remoteAgentHostServiceOverride?: Partial; customizationServiceOverride?: IAgentHostCustomizationService; agentHostTerminalServiceOverride?: Partial; languageModelsServiceOverride?: Partial; workspaceFolders?: readonly URI[]; hideAutoExplainability?: boolean; pendingTreatment?: Promise; requiresWorkspaceTrust?: boolean }) { +function createContribution(disposables: DisposableStore, opts?: { backendSessionScheme?: string; authServiceOverride?: Partial; workingDirectoryResolver?: { resolve(sessionResource: URI): URI | undefined; isNewSession?: (sessionResource: URI) => boolean }; languageModels?: ReadonlyMap; provisionalServiceOverride?: Partial; languageModelToolsServiceOverride?: Partial; configOverrides?: Record; provider?: string; chatSessionsServiceOverride?: Partial; chatDebugServiceOverride?: Partial; remoteAgentHostServiceOverride?: Partial; customizationServiceOverride?: IAgentHostCustomizationService; agentHostTerminalServiceOverride?: Partial; languageModelsServiceOverride?: Partial; workspaceFolders?: readonly URI[]; hideAutoExplainability?: boolean; pendingTreatment?: Promise; requiresWorkspaceTrust?: boolean }) { const { instantiationService, agentHostService, chatAgentService, chatWidgetService, chatService, openerService, trustController, modelService, workingCopyService } = createTestServices(disposables, opts?.workingDirectoryResolver, opts?.authServiceOverride, opts?.languageModels, opts?.provisionalServiceOverride, false, opts?.languageModelToolsServiceOverride, opts?.configOverrides, opts?.chatSessionsServiceOverride, opts?.chatDebugServiceOverride, opts?.remoteAgentHostServiceOverride, opts?.customizationServiceOverride, opts?.agentHostTerminalServiceOverride, opts?.languageModelsServiceOverride, opts?.workspaceFolders); if (opts?.hideAutoExplainability || opts?.pendingTreatment) { @@ -1158,8 +1194,17 @@ function createContribution(disposables: DisposableStore, opts?: { authServiceOv } const listController = createSessionListController(disposables, instantiationService, agentHostService); - const sessionHandler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { + class TestSessionHandler extends AgentHostSessionHandler { + override provideChatSessionContent(resource: URI, token: CancellationToken): Promise { + if (!resource.path.substring(1).startsWith('new-') && !listController.isNewSession(resource)) { + agentHostService.seedSessionMetadata(AgentSession.uri(opts?.provider ?? 'copilot', AgentSession.id(resource))); + } + return super.provideChatSessionContent(resource, token); + } + } + const sessionHandler = disposables.add(instantiationService.createInstance(TestSessionHandler, { provider: opts?.provider ?? 'copilot', + backendSessionScheme: opts?.backendSessionScheme, agentId: 'agent-host-copilot', sessionType: 'agent-host-copilot', fullName: 'Agent Host - Copilot', @@ -1281,6 +1326,9 @@ async function startTurn( ) { const agentId = overrides?.agentId ?? 'agent-host-copilot'; const sessionResource = overrides?.sessionResource ?? URI.from({ scheme: agentId, path: '/new-turntest' }); + if (!sessionResource.path.substring(1).startsWith('new-')) { + agentHostService.seedSessionMetadata(AgentSession.uri('copilot', AgentSession.id(sessionResource))); + } const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); ds.add(toDisposable(() => chatSession.dispose())); @@ -1449,6 +1497,8 @@ suite('AgentHostChatContribution', () => { })); const sessionResource = URI.parse('agent-host-copilot:/session-from-host'); const backendSession = AgentSession.uri('copilot', 'session-from-host'); + instantiationService.invokeFunction(accessor => accessor.get(IAgentHostConnectionsService)) + .registerSessionResource(backendSession, undefined, 'copilot'); const chatUri = 'ahp-chat:/host-assigned-conversation-73'; const summary = { resource: chatUri, title: 'Remote chat', status: SessionStatus.Idle, modifiedAt: '2026-09-30T12:00:00.000Z' }; const sessionSubscribe = peer.nextRequest('subscribe'); @@ -1931,6 +1981,22 @@ suite('AgentHostChatContribution', () => { }); suite('response resource links', () => { + for (const backendSessionScheme of ['copilotcli', 'ahp-session', 'custom-session']) { + test(`uses the advertised backend scheme ${backendSessionScheme} instead of allocation defaults`, async () => { + const { sessionHandler, agentHostService } = createContribution(disposables, { + provider: 'copilotcli', + backendSessionScheme: 'new-allocation-only', + }); + const backendSession = URI.parse(`${backendSessionScheme}:/remote-session`); + agentHostService.seedSessionMetadata(backendSession, 'copilotcli'); + const subscriptions = sinon.spy(agentHostService, 'getSubscription'); + disposables.add(toDisposable(() => subscriptions.restore())); + await sessionHandler.provideChatSessionContent(URI.parse('agent-host-copilotcli:/remote-session'), CancellationToken.None); + assert.deepStrictEqual(subscriptions.getCalls().filter(call => call.args[0] === StateComponents.Session) + .map(call => call.args[1].toString()), [backendSession.toString()]); + }); + } + for (const host of ['local', 'WSL']) { for (const message of ['/sandbox policy', '/SB policy ', 'Hello']) { test(`opens the ${host} sandbox policy only for its submitted slash command (${message})`, () => runWithFakedTimers({ useFakeTimers: true }, async () => { @@ -5394,6 +5460,40 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(rebindCalls, 0); })); + for (const rebound of [URI.parse('ahp-session:/fresh-final'), undefined]) { + test(`final allocation is selected before exposing the item after rebind (${!!rebound})`, async () => { + const { instantiationService, agentHostService, newSessionFolderService } = createTestServices(disposables); + const untitled = URI.parse('agent-host-copilot:/untitled-final-selection'); + const latestDirectory = URI.file('/latest-folder'); + newSessionFolderService.setFolder(untitled, latestDirectory); + let candidate: URI | undefined; + const metadataResources: string[] = []; + instantiationService.stub(IAgentHostUntitledProvisionalSessionService, new class extends mock() { + override async tryRebind(_old: URI, requested: URI): Promise { candidate = requested; return rebound; } + override getProvisionalWorkingDirectories(): readonly URI[] { return [latestDirectory]; } + override setSessionCreationMetadata(resource: URI): void { metadataResources.push(resource.toString()); } + }()); + const controller = createSessionListController(disposables, instantiationService, agentHostService); + const item = await controller.newChatSessionItem({ prompt: 'Hello', untitledResource: untitled, _meta: { 'test.creation': true } }, CancellationToken.None); + assert.ok(candidate && item); + assert.deepStrictEqual({ + changed: item.resource.path !== candidate.path, + final: rebound ? item.resource.path : undefined, + oldPending: controller.isNewSession(candidate), + newPending: controller.isNewSession(item.resource), + folder: newSessionFolderService.getFolder(item.resource)?.toString(), + metadata: metadataResources, + }, { + changed: true, + final: rebound?.path, + oldPending: false, + newPending: true, + folder: latestDirectory.toString(), + metadata: [candidate.toString(), item.resource.toString()], + }); + }); + } + test('newChatSessionItem routes the store-selected folder as the working directory in multi-root windows', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { instantiationService, agentHostService, newSessionFolderService } = createTestServices(disposables); @@ -5482,6 +5582,71 @@ suite('AgentHostChatContribution', () => { suite('session ID resolution', () => { + for (const provider of ['copilotcli', 'codex', 'claude']) { + for (const scheme of [provider, 'ahp-session', 'session-store']) { + test(`cold ${provider} content waits for the advertised ${scheme} identity`, async () => { + const { instantiationService, agentHostService } = createTestServices(disposables); + const backend = URI.from({ scheme, path: '/cold-restored', ...(scheme === 'session-store' ? { authority: 'tenant', query: 'revision=2' } : {}) }); + const resource = backend.with({ scheme: `agent-host-${provider}` }); + const summary = { + resource: backend.toString(), provider, title: 'Restored', + status: SessionStatus.Idle, createdAt: new Date(0).toISOString(), modifiedAt: new Date(0).toISOString(), + }; + agentHostService.sessionStates.set(backend.toString(), { ...createSessionState(summary), lifecycle: SessionLifecycle.Ready, activeClients: [] }); + const listed = new DeferredPromise(); + let listCalls = 0; + agentHostService.listSessions = async () => { + listCalls++; + await listed.p; + return [{ session: backend, provider, summary: 'Restored', startTime: 0, modifiedTime: 0 }]; + }; + const handler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { + provider, agentId: resource.scheme, sessionType: resource.scheme, + fullName: provider, description: 'test', connection: agentHostService, connectionAuthority: 'local', + })); + + const opening = handler.provideChatSessionContent(resource, CancellationToken.None); + await timeout(0); + const before = { listCalls, subscribed: agentHostService.hasLiveSubscription(backend.toString()) }; + await listed.complete(); + disposables.add(await opening); + const connections = instantiationService.invokeFunction(accessor => accessor.get(IAgentHostConnectionsService)); + + assert.deepStrictEqual({ + before, + backend: connections.resolveSessionResourceIdentity(resource)?.backendSession.toString(), + subscribed: agentHostService.hasLiveSubscription(backend.toString()), + }, { before: { listCalls: 1, subscribed: false }, backend: backend.toString(), subscribed: true }); + }); + } + } + + test('missing advertised identities do not probe a guessed legacy address', async () => { + const { instantiationService, agentHostService } = createTestServices(disposables); + agentHostService.listSessions = async () => []; + const handler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { + provider: 'codex', agentId: 'agent-host-codex', sessionType: 'agent-host-codex', + fullName: 'Codex', description: 'test', connection: agentHostService, connectionAuthority: 'local', + })); + await assert.rejects(handler.provideChatSessionContent(URI.parse('agent-host-codex:/unknown'), CancellationToken.None), /not advertised/); + assert.strictEqual(agentHostService.hasLiveSubscription('codex:/unknown'), false); + }); + + for (const unknown of ['agent-host-codex://tenant/shared?revision=2', 'session-store:/shared']) { + test(`a neighboring legacy ID cannot claim complete unadvertised resource ${unknown}`, async () => { + const { instantiationService, agentHostService } = createTestServices(disposables); + const connections = instantiationService.invokeFunction(accessor => accessor.get(IAgentHostConnectionsService)); + connections.registerSessionResource(URI.parse('codex:/shared'), undefined, 'codex'); + agentHostService.listSessions = async () => []; + const handler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { + provider: 'codex', agentId: 'agent-host-codex', sessionType: 'agent-host-codex', + fullName: 'Codex', description: 'test', connection: agentHostService, connectionAuthority: 'local', + })); + await assert.rejects(handler.provideChatSessionContent(URI.parse(unknown), CancellationToken.None), /not advertised/); + assert.strictEqual(agentHostService.hasLiveSubscription('codex:/shared'), false); + }); + } + for (const isSystemInitiated of [true, false, undefined]) { test(`preserves request origin for isSystemInitiated=${isSystemInitiated}`, () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); @@ -11522,6 +11687,7 @@ suite('AgentHostChatContribution', () => { const { instantiationService, agentHostService, chatAgentService, chatWidgetService, modelService, workingCopyService } = createTestServices(disposables); const sessionType = 'remote-cloudsandbox_environment-one-copilot'; const sessionResource = URI.from({ scheme: sessionType, path: '/session-one' }); + agentHostService.seedSessionMetadata(AgentSession.uri('ahp-session', 'session-one'), 'copilot'); const sessionHandler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { provider: 'copilot', backendSessionScheme: 'ahp-session', diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatInputPicker.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatInputPicker.test.ts index 5175d7abfa32cd..54e58de22b444f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatInputPicker.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatInputPicker.test.ts @@ -22,7 +22,7 @@ import { AMBIENT_AGENT_HOST_AUTHORITY, IAgentHostConnectionsService, IAgentHostS import { remoteAgentHostSessionTypeId } from '../../../../../../platform/agentHost/common/agentHostSessionType.js'; import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { SessionState } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; -import { toAgentHostBackendSessionUri } from '../../../browser/agentSessions/agentHost/agentHostSessionUri.js'; +import { getLocalAgentHostSessionProvider, toAgentHostBackendSessionUri } from '../../../browser/agentSessions/agentHost/agentHostSessionUri.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { ConfigurationTarget, IConfigurationService, IConfigurationValue } from '../../../../../../platform/configuration/common/configuration.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; @@ -198,16 +198,22 @@ suite('AgentHostChatInputPicker - combined mode and permissions', () => { } }); const sessionResolutions = new Map(); + const resolveIdentity = (sessionResource: URI) => { + const known = sessionResolutions.get(sessionResource.toString()); + const provider = getLocalAgentHostSessionProvider(sessionResource); + return known ?? (provider ? { backendSession: sessionResource.with({ scheme: provider, fragment: '' }), connectionAuthority: AMBIENT_AGENT_HOST_AUTHORITY } : undefined); + }; instantiationService.stub(IAgentHostConnectionsService, { ambientConnection: connection, onDidChangeSessionResolution: Event.None, + resolveSessionResourceIdentity: resolveIdentity, resolveSessionResource: sessionResource => { const resolution = sessionResolutions.get(sessionResource.toString()); if (resolution) { return resolution; } - const backendSession = toAgentHostBackendSessionUri(sessionResource); - return backendSession ? { connection, backendSession, connectionAuthority: AMBIENT_AGENT_HOST_AUTHORITY } : undefined; + const identity = resolveIdentity(sessionResource); + return identity ? { ...identity, connection } : undefined; }, }); instantiationService.set(IActionWidgetService, actionWidget); @@ -524,7 +530,7 @@ suite('AgentHostChatInputPicker - combined mode and permissions', () => { const bypassAllowed = read(); managedSandboxAllowsBypass.set(false, undefined); const resource = widget.viewModel!.sessionResource; - const state = setSession(resource, toAgentHostBackendSessionUri(resource)!, instantiationService.get(IAgentHostService)); + const state = setSession(resource, toAgentHostBackendSessionUri(resource, instantiationService.get(IAgentHostConnectionsService))!, instantiationService.get(IAgentHostService)); const running = read(); await permissionPicker['_showPicker'](dom.$('div')); const toggle = actionWidget.items.find(item => item.standaloneToggle)!.standaloneToggle!; @@ -582,7 +588,7 @@ suite('AgentHostChatInputPicker - combined mode and permissions', () => { const { permissionPicker, sandboxReady, setSession, config, configuration, widget, instantiationService } = setup(false); await sandboxReady(); const resource = widget.viewModel!.sessionResource; - const state = setSession(resource, toAgentHostBackendSessionUri(resource)!, instantiationService.get(IAgentHostService)); + const state = setSession(resource, toAgentHostBackendSessionUri(resource, instantiationService.get(IAgentHostConnectionsService))!, instantiationService.get(IAgentHostService)); await configuration.setUserConfiguration(AgentSandboxSettingId.AgentSandboxEnabled, 'off'); delete config.values[SessionConfigKey.SandboxEnabled]; state._meta = withSessionSandboxState(undefined, { enabled: true }); @@ -1418,9 +1424,13 @@ suite('AgentHostChatInputPicker - sandbox toggle', () => { new class extends mock() { override readonly ambientConnection = connection; override readonly onDidChangeSessionResolution = Event.None; + override resolveSessionResourceIdentity(sessionResource: URI) { + const provider = getLocalAgentHostSessionProvider(sessionResource); + return provider ? { backendSession: sessionResource.with({ scheme: provider, fragment: '' }), connectionAuthority: AMBIENT_AGENT_HOST_AUTHORITY } : undefined; + } override resolveSessionResource(sessionResource: URI) { - const backendSession = toAgentHostBackendSessionUri(sessionResource); - return backendSession ? { connection, backendSession, connectionAuthority: AMBIENT_AGENT_HOST_AUTHORITY } : undefined; + const identity = this.resolveSessionResourceIdentity(sessionResource); + return identity ? { ...identity, connection } : undefined; } }(), new class extends mock() { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts index 68529db531e9d5..8fa17452ec871a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts @@ -10,10 +10,11 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js import { CancellationError } from '../../../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; -import { DisposableStore, IReference, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IReference, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { ResourceSet } from '../../../../../../base/common/map.js'; import { extUriBiasedIgnorePathCase } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; +import { hasKey } from '../../../../../../base/common/types.js'; import { constObservable, observableValue, autorun, type IObservable } from '../../../../../../base/common/observable.js'; import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; @@ -21,6 +22,10 @@ import { runWithFakedTimers } from '../../../../../../base/test/common/timeTrave import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; import { ConfigurationTarget, IConfigurationChangeEvent, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { AgentSession, IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; +import { AgentHostConnectionsService } from '../../../../../../platform/agentHost/browser/agentHostConnectionsService.js'; +import { IAgentHostConnectionsService } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { createAgentHostResourceUriMapper } from '../../../../../../platform/agentHost/common/agentHostUri.js'; +import { IRemoteAgentHostService, NullRemoteAgentHostService } from '../../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { CLIENT_SEMANTIC_SEARCH_REFERENCE_NAME, CLIENT_SEMANTIC_SEARCH_TOOL_ID, CopilotSemanticSearchEnabledSettingId, SEMANTIC_SEARCH_TOOL_NAME } from '../../../../../../platform/agentHost/common/semanticSearchConstants.js'; import { CLIENT_TOOL_SEARCH_REFERENCE_NAME, RUNTIME_TOOL_SEARCH_TOOL_NAME } from '../../../../../../platform/agentHost/common/toolSearchConstants.js'; import { agentSandboxDiagnosticsMetaKey } from '../../../../../../platform/agentHost/common/meta/agentSandboxDiagnostics.js'; @@ -39,6 +44,7 @@ import { ILanguageModelsService } from '../../../common/languageModels.js'; import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; import { PieceCtorKind, PromptNodeType } from '../../../common/tools/promptTsxTypes.js'; import { IProductService } from '../../../../../../platform/product/common/productService.js'; +import { IPathService } from '../../../../../../platform/path/common/pathService.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { IConfigurationResolverService } from '../../../../../services/configurationResolver/common/configurationResolver.js'; @@ -740,6 +746,7 @@ suite('AgentHostClientTools', () => { override readonly onAgentHostExit = Event.None; override readonly onAgentHostStart = Event.None; override readonly initializeResult = constObservable(undefined); + override readonly resourceUris = createAgentHostResourceUriMapper('local'); private readonly _liveSubscriptions = new Map }>(); public dispatchedActions: { channel: string; action: SessionAction | ChatAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction }[] = []; @@ -748,6 +755,17 @@ suite('AgentHostClientTools', () => { public resourceReadEncoding = ContentEncoding.Utf8; public readonly resourceReadResponses = new Map>(); + override async listSessions() { + const sessions = new Set([AgentSession.uri('copilot', 'session-1').toString()]); + for (const [resource, entry] of this._liveSubscriptions) { + const parent = parseDefaultChatUri(resource); + if (parent || hasKey(entry.state, { provider: true })) { + sessions.add(parent ?? resource); + } + } + return [...sessions].map(resource => ({ session: URI.parse(resource), provider: 'copilot', startTime: 0, modifiedTime: 0 })); + } + override async resourceRead(uri: URI) { this.resourceReadUris.push(uri); return this.resourceReadResponses.get(uri.toString()) @@ -862,6 +880,10 @@ suite('AgentHostClientTools', () => { } as Partial; instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IAgentHostService, connection); + instantiationService.stub(IRemoteAgentHostService, new NullRemoteAgentHostService()); + instantiationService.stub(IPathService, { registerPathProvider: () => Disposable.None }); + instantiationService.stub(IAgentHostConnectionsService, disposables.add(instantiationService.createInstance(AgentHostConnectionsService))); instantiationService.stub(IUriIdentityService, new class extends mock() { override readonly extUri = extUriBiasedIgnorePathCase; }); @@ -3030,6 +3052,7 @@ suite('AgentHostClientTools', () => { const backendSession = AgentSession.uri('copilot', `session-${index}`); const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: `/session-${index}` }); const chat = buildDefaultChatUri(backendSession.toString()); + connection.applySessionAction(backendSession, { type: ActionType.SessionTitleChanged, title: 'Test' }); const subscription = disposables.add(new ChatStateSubscription(chat, connection.clientId, () => ++clientSeq, () => { })); disposables.add(subscription.onDidChange(state => connection.setChatState(chat, state))); await handler.provideChatSessionContent(sessionResource, CancellationToken.None); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCustomizationService.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCustomizationService.test.ts index 5d04782a566a32..bfb39d365905be 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCustomizationService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCustomizationService.test.ts @@ -15,7 +15,7 @@ import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IAgentHostConnectionsService } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; -import { withMcpServerDisplayNameMeta, withMcpServerSourceMeta } from '../../../../../../platform/agentHost/common/meta/mcpCustomizationMeta.js'; +import { withMcpServerControllingSettingMeta, withMcpServerDisplayNameMeta, withMcpServerSourceMeta, withMcpServerSourcePluginMeta } from '../../../../../../platform/agentHost/common/meta/mcpCustomizationMeta.js'; import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ActionType, type ActionEnvelope } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { CustomizationEnablementKind, CustomizationType, McpAuthRequiredReason, McpServerCustomization, McpServerStatus, type Customization, type CustomizationEnablement } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; @@ -310,6 +310,55 @@ suite('AbstractAgentHostCustomizationService', () => { })), sources.map(source => ({ displayName: `${source} display`, source, sourceUri: undefined }))); }); + test('exposes host-reported display names, source plugins and controlling settings only when the host publishes them', () => { + const sut = createSut(); + const session = URI.parse('vscode-agent-session:///session-1'); + sut.setTarget(session, new FakeTarget([ + { ...mcpServer('host-id-7f3a', 'catalog-entry-7f3a'), _meta: withMcpServerDisplayNameMeta(withMcpServerSourceMeta(undefined, 'managed'), 'Linear') }, + { ...mcpServer('host-id-9c1e', 'computer-use'), _meta: withMcpServerSourcePluginMeta(withMcpServerSourceMeta(undefined, 'builtin'), 'computer-use') }, + mcpServer('host-id-2b4d', 'without-extension'), + { ...mcpServer('vscode-host-github', 'github-mcp-server'), _meta: withMcpServerControllingSettingMeta(withMcpServerSourceMeta(undefined, 'builtin'), 'chat.agentHost.githubMcpServer.enabled') }, + // A conforming non-VS Code host may bundle its own server under the same name without declaring a setting. + { ...mcpServer('urn:other-host:servers/0', 'github-mcp-server'), uri: 'https://other-host.example/servers/github', _meta: withMcpServerSourceMeta(undefined, 'builtin') }, + ])); + + assert.deepStrictEqual(sut.getMcpServers(session).map(server => ({ + name: server.name, displayName: server.displayName, sourcePluginName: server.sourcePluginName, controllingSettingId: server.controllingSettingId, + })), [ + { name: 'catalog-entry-7f3a', displayName: 'Linear', sourcePluginName: undefined, controllingSettingId: undefined }, + { name: 'computer-use', displayName: undefined, sourcePluginName: 'computer-use', controllingSettingId: undefined }, + { name: 'without-extension', displayName: undefined, sourcePluginName: undefined, controllingSettingId: undefined }, + { name: 'github-mcp-server', displayName: undefined, sourcePluginName: undefined, controllingSettingId: 'chat.agentHost.githubMcpServer.enabled' }, + { name: 'github-mcp-server', displayName: undefined, sourcePluginName: undefined, controllingSettingId: undefined }, + ]); + }); + + test('exposes the agent host configuration only for top-level servers configured there', () => { + const sut = createSut(); + const session = URI.parse('vscode-agent-session:///session-1'); + const configured = { type: 'http', url: 'http://localhost:2134' }; + const pluginServerConfiguration = { type: 'stdio', command: 'plugin-server' }; + const target = new class extends FakeTarget { + readonly rootConfig = { + schema: { type: 'object' as const, properties: {} }, + values: { mcpServers: { 'my-mcp-server-618d857f': configured, 'plugin-server': pluginServerConfiguration, 'not-an-object': 'http://localhost:2134' } }, + }; + }([ + { ...mcpServer('added', 'my-mcp-server-618d857f'), uri: 'mcp-top-level:copilotcli:session-1:my-mcp-server-618d857f' }, + { ...mcpServer('inherited', 'constructor'), uri: 'mcp-top-level:copilotcli:session-1:constructor' }, + { ...mcpServer('malformed', 'not-an-object'), uri: 'mcp-top-level:copilotcli:session-1:not-an-object' }, + { type: CustomizationType.Plugin, id: 'plugin-1', uri: 'file:///plugin-1', name: 'Plugin One', children: [mcpServer('plugin-child', 'plugin-server')] } as unknown as Customization, + ]); + sut.setTarget(session, target); + + assert.deepStrictEqual(sut.getMcpServers(session).map(server => ({ name: server.name, hostConfiguration: server.hostConfiguration })), [ + { name: 'my-mcp-server-618d857f', hostConfiguration: configured }, + { name: 'constructor', hostConfiguration: undefined }, + { name: 'not-an-object', hostConfiguration: undefined }, + { name: 'plugin-server', hostConfiguration: undefined }, + ]); + }); + test('preserves global and session decisions when re-enabling workspace enablement', () => { const sut = createSut(); const session = URI.parse('vscode-agent-session:///session-1'); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostGenericConfigChips.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostGenericConfigChips.test.ts index 06afe02e4a9413..d204c25db9756b 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostGenericConfigChips.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostGenericConfigChips.test.ts @@ -66,6 +66,57 @@ function createSubscription(): IAgentSubscription { suite('AgentHostGenericConfigChips', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + for (const provider of ['copilotcli', 'codex', 'claude']) { + for (const remote of [false, true]) { + test(`peer selection subscribes only to its owning ${provider} session (${remote ? 'remote' : 'local'})`, () => { + const acquired: string[] = []; + const released: string[] = []; + const agentHost = new class extends mock() { + override readonly onAgentHostStart = Event.None; + override readonly onAgentHostExit = Event.None; + override readonly onDidNotification = Event.None; + override readonly resourceUris = identityAgentHostResourceUriMapper; + override getSubscription(_kind: T, resource: URI): IReference> { + acquired.push(resource.toString()); + return { object: createSubscription(), dispose: () => released.push(resource.toString()) }; + } + }(); + const remoteService = new class extends mock() { + override readonly onDidChangeConnections = Event.None; + override readonly connections: readonly IRemoteAgentHostConnectionInfo[] = [{ address: 'host', name: 'Host', status: { kind: 'connected' } }]; + override getConnection() { return agentHost; } + override getConnectionByAuthority() { return agentHost; } + }(); + const connections = disposables.add(new AgentHostConnectionsService(agentHost, remoteService, new TestPathService(), new NullLogService())); + const backend = URI.parse(`${provider}:/parent`); + const resource = connections.getSessionResource(backend, remote ? 'host' : AMBIENT_AGENT_HOST_AUTHORITY)!; + for (const fragment of ['', 'first-peer', 'second-peer']) { + const widget = new class extends mock() { + override readonly onDidChangeViewModel = Event.None; + override readonly viewModel = new class extends mock() { + override readonly sessionResource = resource.with({ fragment }); + }(); + }(); + const provisional = new class extends mock() { + override readonly onDidChange = Event.None; + override get() { return undefined; } + }(); + const chips = disposables.add(new AgentHostGenericConfigChips(widget, + disposables.add(new TestInstantiationService()), connections, provisional, + new class extends mock() { }(), + new class extends mock() { }(), + new class extends mock() { }(), + )); + chips.dispose(); + } + assert.deepStrictEqual({ acquired, released }, { + acquired: [backend.toString(), backend.toString(), backend.toString()], + released: [backend.toString(), backend.toString(), backend.toString()], + }); + }); + } + } + test('moves its subscription when the provisional generation changes', () => { const sessionResource = URI.parse('agent-host-copilot:/untitled-test'); const firstBackend = URI.parse('copilot:/first-generation'); @@ -99,6 +150,9 @@ suite('AgentHostGenericConfigChips', () => { disposables.add(new TestInstantiationService()), new class extends mock() { override readonly onDidChangeSessionResolution = Event.None; + override resolveSessionResourceIdentity() { + return { connectionAuthority: AMBIENT_AGENT_HOST_AUTHORITY, backendSession: firstBackend }; + } override resolveSessionResource() { return { connection: agentHostService, connectionAuthority: AMBIENT_AGENT_HOST_AUTHORITY, backendSession: firstBackend }; } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostModeSynchronizer.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostModeSynchronizer.test.ts index 24701c193b309a..1aeaaa7bd45eb5 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostModeSynchronizer.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostModeSynchronizer.test.ts @@ -8,6 +8,8 @@ import { timeout } from '../../../../../../base/common/async.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; +import { upcastPartial } from '../../../../../../base/test/common/mock.js'; +import { IAgentHostConnectionsService } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { agentHostAgentPickerStorageKey } from '../../../../../../platform/agentHost/common/customAgents.js'; import { StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; @@ -90,7 +92,9 @@ suite('AgentHostModeSynchronizer', () => { const storageService = store.add(new TestStorageService()); const environmentService = { isSessionsWindow: false } as IWorkbenchEnvironmentService; - const synchronizer = store.add(new AgentHostModeSynchronizer(widgetService, provisionalSessionService, storageService, environmentService)); + const synchronizer = store.add(new AgentHostModeSynchronizer(widgetService, provisionalSessionService, storageService, environmentService, upcastPartial({ + resolveSessionResourceIdentity: resource => ({ connectionAuthority: 'local', backendSession: resource.with({ scheme: resource.scheme.substring('agent-host-'.length) }) }), + }))); return { modeChanges, diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostSessionUri.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostSessionUri.test.ts new file mode 100644 index 00000000000000..3109930d4704f6 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostSessionUri.test.ts @@ -0,0 +1,64 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { URI } from '../../../../../../base/common/uri.js'; +import { mock } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { AMBIENT_AGENT_HOST_AUTHORITY, IAgentHostConnectionsService } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; +import { applyAgentHostSessionConfigChange } from '../../../browser/agentSessions/agentHost/applyAgentHostSessionConfig.js'; +import { toAgentHostBackendSessionUri } from '../../../browser/agentSessions/agentHost/agentHostSessionUri.js'; +import { IAgentHostSessionWorkingDirectoryResolver } from '../../../browser/agentSessions/agentHost/agentHostSessionWorkingDirectoryResolver.js'; +import { IAgentHostUntitledProvisionalSessionService } from '../../../browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.js'; + +suite('Agent Host committed configuration identity', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + for (const provider of ['copilotcli', 'codex', 'claude']) { + for (const backendResource of [`${provider}:/shared`, 'ahp-session:/shared', 'session-store://tenant/shared?revision%3D2']) { + test(`${provider} config changes use the advertised backend ${backendResource}`, async () => { + const resource = URI.parse(`agent-host-${provider}:/shared`); + const backendSession = URI.parse(backendResource); + const dispatched: string[] = []; + const refreshedProviders: string[] = []; + const connectionsService = new class extends mock() { + override resolveSessionResourceIdentity(): { backendSession: URI; connectionAuthority: string } { + return { backendSession, connectionAuthority: AMBIENT_AGENT_HOST_AUTHORITY }; + } + }(); + const agentHostService = new class extends mock() { + override dispatch(channel: string): void { dispatched.push(channel); } + override getSubscriptionUnmanaged() { + return { value: undefined, verifiedValue: undefined, onDidChange: Event.None, onWillApplyAction: Event.None, onDidApplyAction: Event.None }; + } + }(); + const provisionalService = new class extends mock() { + override async refreshResolvedConfig(_resource: URI, provider: string): Promise { refreshedProviders.push(provider); } + }(); + const applied = await applyAgentHostSessionConfigChange(resource, { mode: 'plan' }, { + agentHostService, + connectionsService, + provisionalService, + workingDirectoryResolver: new class extends mock() { + override resolve(): undefined { return undefined; } + }(), + workspaceContextService: new class extends mock() { + override getWorkspace() { return { id: 'test', folders: [] }; } + }(), + configurationService: new TestConfigurationService() satisfies IConfigurationService, + }); + assert.deepStrictEqual({ + applied, dispatched, refreshedProviders, + backend: toAgentHostBackendSessionUri(resource, connectionsService)?.toString(), + }, { applied: true, dispatched: [backendResource], refreshedProviders: [provider], backend: backendResource }); + }); + } + } +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts index 1e000457e7d987..58c363857600e3 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { VSCODE_EPHEMERAL_SESSION_META_KEY } from '../../../../../../platform/agentHost/common/meta/agentEphemeralSessionMeta.js'; import { DeferredPromise, timeout } from '../../../../../../base/common/async.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; @@ -21,6 +22,7 @@ import { IAgentCreateSessionConfig, IAgentHostService, IAgentResolveSessionConfi import { AMBIENT_AGENT_HOST_AUTHORITY, IAgentHostConnectionsService, IAgentHostSessionResolution } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { ActionType } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; import type { ResolveSessionConfigResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; +import type { InitializeResult } from '../../../../../../platform/agentHost/common/state/protocol/common/commands.js'; import { CustomizationType, type ClientPluginCustomization, type ConfigSchema, type SessionActiveClient } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; import { IWorkspaceContextService, IWorkspace, IWorkspaceFolder, IWorkspaceFoldersChangeEvent, WorkbenchState } from '../../../../../../platform/workspace/common/workspace.js'; @@ -33,7 +35,7 @@ import { AgentHostUntitledProvisionalSessionService, IAgentHostUntitledProvision import { AgentHostNewSessionFolderService, IAgentHostNewSessionFolderService } from '../../../browser/agentSessions/agentHost/agentHostNewSessionFolderService.js'; import { AgentHostImportConversationStore, IAgentHostImportConversationStore } from '../../../browser/agentSessions/agentHost/agentHostImportConversationStore.js'; import { areCustomizationScopeRootsEqual, IAgentHostActiveClientService } from '../../../browser/agentSessions/agentHost/agentHostActiveClientService.js'; -import { toAgentHostBackendSessionUri } from '../../../browser/agentSessions/agentHost/agentHostSessionUri.js'; +import { getLocalAgentHostSessionProvider } from '../../../browser/agentSessions/agentHost/agentHostSessionUri.js'; // ---- Mocks ----------------------------------------------------------------- @@ -47,6 +49,9 @@ interface IDispatchedAction { class MockAgentHostService extends mock() { declare readonly _serviceBrand: undefined; override readonly clientId = 'test-client'; + override readonly initializeResult = observableValue(this, { + protocolVersion: '0.9.0', serverSeq: 0, snapshots: [], _meta: { 'vscode.agentHost': true }, + }); readonly createCalls: IAgentCreateSessionConfig[] = []; readonly disposed: URI[] = []; @@ -56,6 +61,8 @@ class MockAgentHostService extends mock() { createGate: DeferredPromise | undefined; failNextCreate = false; failNextDispose = false; + enforceStandardTombstones = false; + private readonly _tombstones = new Set(); private readonly _onAgentHostStart = new Emitter(); override readonly onAgentHostStart = this._onAgentHostStart.event; @@ -88,6 +95,9 @@ class MockAgentHostService extends mock() { override async createSession(config?: IAgentCreateSessionConfig): Promise { assert.ok(config?.session); this.createCalls.push(config); + if (this.enforceStandardTombstones && this._tombstones.has(config.session.toString())) { + throw new Error(`Session storage identity is already in use: ${config.session.toString()}`); + } if (this.failNextCreate) { this.failNextCreate = false; throw new Error('create failed'); @@ -107,6 +117,12 @@ class MockAgentHostService extends mock() { throw new Error('dispose failed'); } this.disposed.push(session); + if (this.enforceStandardTombstones && session.scheme === 'ahp-session') { + const creation = [...this.createCalls].reverse().find(call => call.session?.toString() === session.toString()); + if (creation?._meta?.[VSCODE_EPHEMERAL_SESSION_META_KEY] !== true) { + this._tombstones.add(session.toString()); + } + } } fireAgentHostStart(): void { @@ -223,7 +239,8 @@ suite('AgentHostUntitledProvisionalSessionService', () => { if (sessionResolutions.has(sessionResource)) { return sessionResolutions.get(sessionResource); } - const backendSession = toAgentHostBackendSessionUri(sessionResource); + const provider = getLocalAgentHostSessionProvider(sessionResource); + const backendSession = provider ? sessionResource.with({ scheme: provider, fragment: '' }) : undefined; return backendSession ? { connection: agentHost, connectionAuthority: AMBIENT_AGENT_HOST_AUTHORITY, backendSession } : undefined; }, }); @@ -297,6 +314,54 @@ suite('AgentHostUntitledProvisionalSessionService', () => { }); }); + for (const provider of ['copilotcli', 'codex', 'claude']) { + for (const supported of [false, true]) { + test(`${provider} provisional drafts negotiate addressing and retain their frontend resource (${supported})`, async () => { + agentHost.initializeResult.set({ + protocolVersion: '0.9.0', serverSeq: 0, snapshots: [], _meta: { 'vscode.agentHost': true, 'vscode.ahpSessionUris': supported }, + }, undefined); + const draft = URI.parse(`agent-host-${provider}:/untitled-interop`); + const committed = URI.parse(`agent-host-${provider}:/final-interop`); + const initial = await provisional.getOrCreate(draft, provider, undefined); + const rebound = await provisional.tryRebind(draft, committed, provider); + assert.deepStrictEqual({ + initialScheme: initial?.scheme, + rebound: rebound?.toString(), + resolved: provisional.get(committed)?.toString(), + }, { + initialScheme: supported ? 'ahp-session' : provider, + rebound: `${supported ? 'ahp-session' : provider}:/final-interop`, + resolved: `${supported ? 'ahp-session' : provider}:/final-interop`, + }); + }); + } + } + + for (const provider of ['copilotcli', 'codex', 'claude']) { + test(`${provider} a pre-initialize draft retains its identity while rebind uses the negotiated host`, async () => { + agentHost.initializeResult.set(undefined, undefined); + const draft = URI.parse(`agent-host-${provider}:/untitled-before-initialize`); + const committed = URI.parse(`agent-host-${provider}:/after-initialize`); + const initial = await provisional.getOrCreate(draft, provider, undefined); + agentHost.initializeResult.set({ + protocolVersion: '0.9.0', serverSeq: 0, snapshots: [], _meta: { 'vscode.agentHost': true, 'vscode.ahpSessionUris': true }, + }, undefined); + const retained = await provisional.getOrCreate(draft, provider, undefined); + const rebound = await provisional.tryRebind(draft, committed, provider); + assert.deepStrictEqual({ + initialScheme: initial?.scheme, + retained: retained?.toString(), + rebound: rebound?.toString(), + frontend: committed.toString(), + }, { + initialScheme: provider, + retained: initial?.toString(), + rebound: 'ahp-session:/after-initialize', + frontend: `agent-host-${provider}:/after-initialize`, + }); + }); + } + test('publishes active-client customizations before the first prompt and keeps them updated', async () => { const first: ClientPluginCustomization = { type: CustomizationType.Plugin, @@ -1217,6 +1282,84 @@ suite('AgentHostUntitledProvisionalSessionService', () => { }); }); + for (const provider of ['copilotcli', 'codex', 'claude']) { + test(`standard ${provider} rebind retries with a fresh identity after retiring a stale candidate`, async () => { + agentHost.initializeResult.set({ + protocolVersion: '0.9.0', serverSeq: 0, snapshots: [], + _meta: { 'vscode.agentHost': true, 'vscode.ahpSessionUris': true }, + }, undefined); + agentHost.enforceStandardTombstones = true; + const ui = URI.from({ scheme: `agent-host-${provider}`, path: '/untitled-standard-race' }); + const realUi = ui.with({ path: '/standard-race' }); + await provisional.getOrCreate(ui, provider, undefined); + const creationCount = agentHost.createCalls.length; + const gate = new DeferredPromise(); + cleanup.add({ dispose: () => gate.cancel() }); + agentHost.createGate = gate; + + const rebind = provisional.tryRebind(ui, realUi, provider); + await timeout(0); + const configChange = provisional.applyConfigChange(ui, provider, undefined, { isolation: 'worktree' }); + await gate.complete(); + const [rebound] = await Promise.all([rebind, configChange]); + assert.ok(rebound); + const boundResource = realUi.with({ path: rebound.path }); + const finalCreates = agentHost.createCalls.slice(creationCount); + + assert.deepStrictEqual({ + count: finalCreates.length, + distinct: new Set(finalCreates.map(call => call.session?.toString())).size, + firstRetired: agentHost.disposed.some(session => session.toString() === finalCreates[0].session?.toString()), + scheme: rebound.scheme, + mapping: provisional.get(boundResource)?.toString(), + unpublishedCandidate: provisional.get(realUi), + config: finalCreates.at(-1)?.config, + }, { + count: 2, + distinct: 2, + firstRetired: true, + scheme: 'ahp-session', + mapping: rebound.toString(), + unpublishedCandidate: undefined, + config: { isolation: 'worktree' }, + }); + }); + } + + for (const provider of ['copilotcli', 'codex', 'claude']) { + test(`standard ${provider} rebind keeps the latest folder after retiring a stale candidate`, async () => { + agentHost.initializeResult.set({ + protocolVersion: '0.9.0', serverSeq: 0, snapshots: [], + _meta: { 'vscode.agentHost': true, 'vscode.ahpSessionUris': true }, + }, undefined); + agentHost.enforceStandardTombstones = true; + const ui = URI.from({ scheme: `agent-host-${provider}`, path: '/untitled-folder-race' }); + const real = ui.with({ path: '/folder-race' }); + const initial = URI.file('/initial-folder'); + const latest = URI.file('/latest-folder'); + folderService.setFolder(ui, initial); + await provisional.getOrCreate(ui, provider, initial); + const creationCount = agentHost.createCalls.length; + const gate = new DeferredPromise(); + cleanup.add({ dispose: () => gate.cancel() }); + agentHost.createGate = gate; + + const rebinding = provisional.tryRebind(ui, real, provider); + await timeout(0); + folderService.setFolder(ui, latest); + await gate.complete(); + const rebound = await rebinding; + assert.ok(rebound); + await provisional.waitForPending(ui); + const attempts = agentHost.createCalls.slice(creationCount); + assert.deepStrictEqual({ + distinct: new Set(attempts.map(call => call.session?.toString())).size, + finalDirectories: attempts.at(-1)?.workingDirectories?.map(directory => directory.toString()), + mapping: provisional.get(real.with({ path: rebound.path }))?.toString(), + }, { distinct: 2, finalDirectories: [latest.toString()], mapping: rebound.toString() }); + }); + } + test('tryRebind disposes its candidate when the old entry is retired during creation', async () => { const ui = untitledChatUri('rebind-dispose-race'); const realUi = URI.from({ scheme: 'agent-host-copilot', path: '/real-dispose-race' }); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionSettingsFileSystemProvider.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionSettingsFileSystemProvider.test.ts index 02ffdedd089026..26c690bd20a48e 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionSettingsFileSystemProvider.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionSettingsFileSystemProvider.test.ts @@ -21,6 +21,7 @@ import { TestInstantiationService } from '../../../../../../platform/instantiati import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; +import { AMBIENT_AGENT_HOST_AUTHORITY, IAgentHostConnectionsService } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { AGENT_HOST_ENABLED_CONTEXT_KEY } from '../../../../../../platform/agentHost/common/agentHostEnablementService.js'; import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import type { IRootConfigChangedAction, ClientAnnotationsAction, INotification, SessionAction, TerminalAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; @@ -544,12 +545,17 @@ suite('workbench.action.chat.openAgentSessionSettings', () => { } as unknown as IAgentSession; } - async function invokeWithContext(context: IAgentSession | IMarshalledAgentSessionContext | undefined): Promise<{ resource: URI | undefined; pinned: boolean | undefined }[]> { + async function invokeWithContext(context: IAgentSession | IMarshalledAgentSessionContext | undefined, backendSession = BACKEND_SESSION, resource = CHAT_SESSION_RESOURCE): Promise<{ resource: URI | undefined; pinned: boolean | undefined }[]> { const command = CommandsRegistry.getCommand(ACTION_ID); assert.ok(command, 'command is registered'); const opened: { resource: URI | undefined; pinned: boolean | undefined }[] = []; const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(IAgentHostConnectionsService, { + resolveSessionResourceIdentity: sessionResource => sessionResource.toString() === resource.toString() + ? { backendSession, connectionAuthority: AMBIENT_AGENT_HOST_AUTHORITY } + : undefined, + }); instantiationService.stub(IEditorService, new class extends mock() { override async openEditor(...args: unknown[]): Promise { const editor = args[0] as IResourceEditorInput; @@ -568,6 +574,15 @@ suite('workbench.action.chat.openAgentSessionSettings', () => { assert.deepStrictEqual(opened, [{ resource: agentSessionSettingsUri(BACKEND_SESSION), pinned: true }]); }); + for (const provider of ['copilotcli', 'codex', 'claude']) { + test(`run() retains the advertised standard backend for ${provider} settings`, async () => { + const resource = URI.parse(`agent-host-${provider}:/shared`); + const backend = URI.parse('ahp-session:/shared'); + const opened = await invokeWithContext(makeAgentSession(resource), backend, resource); + assert.deepStrictEqual(opened, [{ resource: agentSessionSettingsUri(backend), pinned: true }]); + }); + } + test('run() with a marshalled agent-session context routes via context.session, ignoring context.sessions', async () => { const session = makeAgentSession(CHAT_SESSION_RESOURCE); const otherSession = makeAgentSession(URI.from({ scheme: 'agent-host-copilotcli', path: '/other' })); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsControl.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsControl.test.ts index 13b43fc13bd4f3..bbabbfbebdb658 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsControl.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsControl.test.ts @@ -20,6 +20,9 @@ import { URI } from '../../../../../../base/common/uri.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { observableValue } from '../../../../../../base/common/observable.js'; import { timeout } from '../../../../../../base/common/async.js'; +import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { AgentSessionsGrouping } from '../../../browser/agentSessions/agentSessionsFilter.js'; +import { toDisposable } from '../../../../../../base/common/lifecycle.js'; suite('AgentSessionsControl', () => { @@ -160,6 +163,68 @@ suite('AgentSessionsControl', () => { }); }); + test('temporarily reveals a filtered row in More and observes its real open action', async () => { + const sessions = Array.from({ length: 6 }, (_, index) => ({ + ...createSession(URI.parse(`test:/session-${index}`), `Session ${index}`), + timing: { created: 10 - index, lastRequestStarted: undefined, lastRequestEnded: undefined }, + })); + const target = sessions[5]; + const model: IAgentSessionsModel = { + sessions, resolved: true, + getSession: resource => sessions.find(session => session.resource.toString() === resource.toString()), + observeSession: () => { throw new Error('Not implemented'); }, + onWillResolve: Event.None, onDidResolve: Event.None, + onDidChangeSessions: Event.None, onDidChangeSessionArchivedState: Event.None, + resolve: async () => { }, + }; + const instantiation = workbenchInstantiationService(undefined, store); + instantiation.stub(IChatSessionsService, new class extends mock() { + override getChatSessionContribution() { return undefined; } + override resolveChatSessionItem = async () => undefined; + }); + instantiation.stub(IVoicePlaybackService, new class extends mock() { + override readonly pendingResponseVersion = observableValue(this, 0); + override hasPendingResponse = () => false; + }); + instantiation.stub(IAgentSessionsService, new class extends mock() { + override readonly model = model; + override readonly onDidChangeSessionArchivedState = Event.None; + override getSession = (resource: URI) => model.getSession(resource); + }); + const opened: string[] = []; + const observed: string[] = []; + const filter = { ...createFilter(), exclude: (session: IAgentSession) => session === target, groupResults: () => AgentSessionsGrouping.Capped, reset: () => assert.fail('Must not reset saved filters') }; + const container = document.body.appendChild(document.createElement('div')); + container.style.cssText = 'width: 500px; height: 500px;'; + store.add(toDisposable(() => container.remove())); + const control = store.add(instantiation.createInstance(AgentSessionsControl, container, { + overrideStyles: {}, filter, source: 'test', + createNewChat: () => { }, getHoverPosition: () => HoverPosition.BELOW, + trackActiveEditorSession: () => false, + overrideSessionOpen: async resource => { opened.push(resource.toString()); }, + })); + store.add(control.onDidOpenSession(resource => observed.push(resource.toString()))); + await timeout(0); // Let the initial asynchronous tree input finish before expanding sections. + control.layout(500, 500); + await control.update(); + assert.strictEqual(control.getSessionElement(target.resource), undefined); + const reveal = await control.revealSession(target.resource, CancellationToken.None); + assert.ok(reveal); + store.add(reveal); + assert.deepStrictEqual(opened, [], 'reveal is not a session open'); + await control.update(); + const row = control.getSessionElement(target.resource); + assert.ok(row, 'the exact row remains resolvable after refresh'); + row.dispatchEvent(new MouseEvent(EventType.CLICK, { bubbles: true, button: 0 })); + await timeout(0); + reveal.dispose(); + await control.update(); + await timeout(0); + assert.deepStrictEqual({ opened, observed, filteredAgain: !control.getSessionElement(target.resource), stillExcluded: filter.exclude(target) }, { + opened: [target.resource.toString()], observed: [target.resource.toString()], filteredAgain: true, stillExcluded: true, + }); + }); + test('persists session hierarchy expansion state', async () => { const resource = URI.parse('test:/persisted-session'); const child = { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/codexContinuationGuide.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/codexContinuationGuide.test.ts new file mode 100644 index 00000000000000..6482ed13570446 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/codexContinuationGuide.test.ts @@ -0,0 +1,303 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DeferredPromise, timeout } from '../../../../../../base/common/async.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { observableValue } from '../../../../../../base/common/observable.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { mock, upcastPartial } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { AgentSession } from '../../../../../../platform/agentHost/common/agent.js'; +import { IAgentHostConnectionsService, IAgentHostSessionResolution } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; +import { ActionEnvelope, ActionType } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; +import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; +import { ChatState, ComponentToState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; +import { IWorkspaceTrustManagementService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; +import { ICodexContinuationCandidate } from '../../../../../services/agentHost/browser/codexContinuation.js'; +import { CodexContinuationAction, ICodexContinuationService } from '../../../../../services/agentHost/browser/codexContinuationService.js'; +import { IHostService } from '../../../../../services/host/browser/host.js'; +import { onDidSelectOnboardingTarget, ONBOARDING_TARGET_ATTR, resolveOnboardingTarget } from '../../../../onboarding/browser/spotlight/onboardingTarget.js'; +import { ISpotlightPayload } from '../../../../onboarding/browser/spotlight/spotlightTypes.js'; +import { onboardingScenarioRegistry } from '../../../../onboarding/common/onboardingRegistry.js'; +import { OnboardingOutcome } from '../../../../onboarding/common/onboardingScenario.js'; +import { IOnboardingScenarioService } from '../../../../onboarding/common/onboardingScenarioService.js'; +import { CodexContinuationGuide, waitForActiveChatWidget } from '../../../browser/agentSessions/agentHost/codexContinuationGuide.js'; +import { IChatWidget, IChatWidgetService, IChatWidgetViewModelChangeEvent } from '../../../browser/chat.js'; +import { ChatInputPart } from '../../../browser/widget/input/chatInputPart.js'; +import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier } from '../../../common/languageModels.js'; +import { IChatModel } from '../../../common/model/chatModel.js'; + +suite('Codex continuation exact widget guide', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + for (const scenario of ['accepted', 'wrongModel', 'rejected', 'cancelled', 'replaced', 'restricted', 'unmappedSession', 'noSearch', 'rowAccepted', 'rowCancelled', 'wrongSession', 'cancelledDuringOpen', 'rowArchived', 'blocked', 'buttonAccepted', 'rowButtonAccepted', 'buttonsAccepted', 'buttonRejected', 'buttonIneligible', 'buttonBlocked', 'buttonAlreadySelected', 'buttonCancelled', 'rowOpenDeclined', 'rowButtonCancelled'] as const) { + test(`only a committed exact selection completes: ${scenario}`, async () => { + const resource = URI.parse('agent-host-codex:/one'); + const candidate: ICodexContinuationCandidate = { + session: { session: AgentSession.uri('codex', 'one'), startTime: 1, modifiedTime: 2, model: { id: '@provider=openai:gpt' } }, + source: { id: '@provider=openai:gpt', provider: 'codex', name: 'GPT' }, + target: { id: '@provider=vscode-proxy:gpt', provider: 'codex', name: 'GPT Copilot' }, + }; + const target: ILanguageModelChatMetadataAndIdentifier = { identifier: `agent-host-codex:${candidate.target.id}`, metadata: upcastPartial({ id: candidate.target.id, name: candidate.target.name, isUserSelectable: true }) }; + const element = document.createElement('button'); + const foreign = document.createElement('button'); + const row = document.createElement('div'); + for (const targetElement of [element, foreign, row]) { + targetElement.style.cssText = 'position: fixed; width: 100px; height: 30px;'; + mainWindow.document.body.appendChild(targetElement); + store.add(toDisposable(() => targetElement.remove())); + } + const selected = observableValue('selected', undefined); + const selections = store.add(new Emitter<{ fromModelId: string; toModelId: string }>()); + const confirmations = store.add(new Emitter()); + const viewChanged = store.add(new Emitter()); + const sessionOpened = store.add(new Emitter()); + const focused = store.add(new Emitter()); + const startWithRow = ['rowAccepted', 'rowCancelled', 'wrongSession', 'cancelledDuringOpen', 'rowArchived', 'rowButtonAccepted', 'buttonsAccepted', 'rowOpenDeclined', 'rowButtonCancelled'].includes(scenario); + const useRowButton = ['rowButtonAccepted', 'buttonsAccepted', 'rowOpenDeclined', 'rowButtonCancelled'].includes(scenario); + const useModelButton = ['buttonAccepted', 'buttonsAccepted', 'buttonRejected', 'buttonIneligible', 'buttonBlocked', 'buttonAlreadySelected', 'buttonCancelled'].includes(scenario); + const buttonCancellation = store.add(new CancellationTokenSource()); + const openPending = new DeferredPromise(); + const openStarted = new DeferredPromise(); + const blocked = observableValue('blocked', scenario === 'blocked'); + let committed: ChatState | undefined; + let eligible = true; + let replaced = false; + const opened: object[] = []; + const actions: CodexContinuationAction[] = []; + let spotlightShows = 0; + let rowOpens = 0; + const buttonSelections: string[] = []; + const input = upcastPartial({ + availableLanguageModels: [target], selectedLanguageModel: selected, + onDidChangeUserSelectedModel: selections.event, + getModelPickerControl: () => scenario === 'noSearch' ? undefined : { + element: replaced ? foreign : element, open: options => opened.push(options), + select: identifier => { + buttonSelections.push(identifier); + assert.strictEqual(identifier, target.identifier); + selected.set(target, undefined); + selections.fire({ fromModelId: 'source', toModelId: identifier }); + return true; + }, + }, + }); + const widget = upcastPartial({ input, visible: true, viewModel: upcastPartial>({ sessionResource: resource, model: upcastPartial({ isInputBlocked: blocked }) }), onDidChangeViewModel: viewChanged.event, focusInput: () => { } }); + let active: IChatWidget | undefined = startWithRow ? undefined : widget; + const widgets = upcastPartial({ + getAllWidgets: () => [widget], onDidAddWidget: Event.None, + onDidChangeFocusedSession: focused.event, onDidChangeWidgetVisibility: Event.None, + }); + const nudge = upcastPartial({ + trackVisibility: () => toDisposable(() => { }), + revision: observableValue('revision', 0), + resolve: async () => eligible ? candidate : undefined, ownsEpisode: () => true, + log: action => actions.push(action), complete: async () => { actions.push('guideCompleted'); }, + }); + const connection = upcastPartial({ + clientId: 'client', + getSubscriptionUnmanaged: (_kind, uri) => upcastPartial>({ + get verifiedValue() { return (uri.toString() === candidate.session.session.toString() ? { defaultChat: 'ahp-chat:/one/main' } : committed) as never; }, + onDidApplyAction: confirmations.event, + }), + }); + const connections = upcastPartial({ + getSessionResource: () => scenario === 'unmappedSession' ? undefined : resource, + resolveSessionResource: () => upcastPartial({ backendSession: candidate.session.session, connection }), + }); + const onboarding = new class extends mock() { + override async runScenario(id: string, token?: CancellationToken): Promise { + const registered = onboardingScenarioRegistry.getScenario(id)!; + const steps = (registered.presentation.payload as ISpotlightPayload).steps; + assert.strictEqual(steps.length, startWithRow ? 2 : 1); + if (startWithRow) { + const rowStep = steps[0]; + await rowStep.onBeforeShow?.(); + const rowTarget = resolveOnboardingTarget(mainWindow, rowStep.targetId)!; + assert.strictEqual(rowTarget.element, row); + assert.strictEqual(rowStep.primaryAction?.label, 'Open Session'); + spotlightShows++; + rowStep.onDidShow?.(); + assert.deepStrictEqual({ active, opened, rowOpens }, { active: undefined, opened: [], rowOpens: 0 }, 'revealing a row neither opens the chat nor its picker'); + if (scenario === 'rowCancelled') { return OnboardingOutcome.Skipped; } + let rowResult: Promise | undefined; + const listener = rowTarget.onDidSelect!(result => { rowResult = result; }); + try { + if (scenario === 'rowArchived') { eligible = false; } + if (useRowButton) { + const action = rowStep.primaryAction!.run(buttonCancellation.token); + if (scenario === 'rowButtonCancelled') { + await openStarted.p; + buttonCancellation.cancel(); + await openPending.complete(); + await action; + assert.strictEqual(rowResult, undefined, 'late opening must not advance the cancelled step'); + return OnboardingOutcome.Skipped; + } + await action; + } else { + if (scenario !== 'cancelledDuringOpen') { active = widget; } + sessionOpened.fire(scenario === 'wrongSession' ? resource.with({ path: '/other' }) : resource); + } + if (scenario === 'cancelledDuringOpen') { + return OnboardingOutcome.Skipped; + } + if (!rowResult || !await rowResult || token?.isCancellationRequested) { return OnboardingOutcome.Aborted; } + } finally { listener.dispose(); } + } + const step = steps[steps.length - 1]; + await step.onBeforeShow?.(); + if (token?.isCancellationRequested) { return OnboardingOutcome.Aborted; } + let resolved = resolveOnboardingTarget(mainWindow, id); + if (!resolved) { + return OnboardingOutcome.Aborted; + } + spotlightShows++; + step.onDidShow?.(); + await resolved.open?.(); + if (scenario === 'replaced') { + replaced = true; + resolved = resolveOnboardingTarget(mainWindow, id); + assert.ok(resolved, 'replacement control resolves through the same exact owner'); + spotlightShows++; + step.onDidShow?.(); + await resolved.open?.(); + } + assert.strictEqual(resolved.element.getAttribute(ONBOARDING_TARGET_ATTR), id); + assert.strictEqual(step.placement, 'left'); + assert.deepStrictEqual(opened, Array.from({ length: scenario === 'replaced' ? 2 : 1 }, () => ({ + initialFilterValue: target.metadata.name, + initialFocusItemId: target.identifier, + }))); + assert.strictEqual(selected.get(), undefined, 'opening never changes the model'); + assert.strictEqual(step.primaryAction?.label, 'Use Copilot'); + if (scenario === 'cancelled') { return OnboardingOutcome.Dismissed; } + let result: Promise | undefined; + const listener = onDidSelectOnboardingTarget(resolved.element)(promise => { result = promise; }); + try { + if (useModelButton) { + if (scenario === 'buttonIneligible') { eligible = false; } + if (scenario === 'buttonBlocked') { blocked.set(true, undefined); } + if (scenario === 'buttonAlreadySelected') { selected.set(target, undefined); } + const action = step.primaryAction!.run(buttonCancellation.token); + if (scenario === 'buttonCancelled') { buttonCancellation.cancel(); } + await action; + } else { + selected.set(target, undefined); + selections.fire({ fromModelId: 'source', toModelId: scenario === 'wrongModel' ? 'other' : target.identifier }); + } + assert.strictEqual(actions.includes('guideCompleted'), false, 'optimistic changes do not complete'); + committed = upcastPartial({ draft: upcastPartial>({ model: { id: target.metadata.id } }) }); + confirmations.fire(upcastPartial({ action: { type: ActionType.ChatDraftChanged, draft: committed.draft }, origin: { clientId: 'client', clientSeq: 1 }, ...(scenario === 'rejected' || scenario === 'buttonRejected' ? { rejectionReason: 'denied' } : {}) })); + return result && await result && !token?.isCancellationRequested ? OnboardingOutcome.Completed : OnboardingOutcome.Dismissed; + } finally { listener.dispose(); } + } + }(); + const guide = store.add(new CodexContinuationGuide(nudge, connections, onboarding, + upcastPartial({ isWorkspaceTrusted: () => scenario !== 'restricted', onDidChangeTrust: Event.None }), + upcastPartial({ info: () => { } }), + upcastPartial({ hasFocus: true, onDidChangeFocus: Event.None }), widgets)); + let reveals = 0; + let releases = 0; + await guide.run(candidate, 'editorWindow', { + getActiveWidget: () => active, + revealSession: async requested => { + assert.strictEqual(requested.toString(), resource.toString()); + reveals++; + return { + getElement: () => row, onDidOpen: sessionOpened.event, focus: () => { }, dispose: () => { releases++; }, + open: async () => { + rowOpens++; + if (scenario === 'rowOpenDeclined') { return false; } + if (scenario === 'rowButtonCancelled') { await openStarted.complete(); await openPending.p; } + active = widget; + return true; + }, + }; + }, + }); + if (scenario === 'cancelledDuringOpen') { + active = widget; + focused.fire(); + await timeout(0); + assert.deepStrictEqual(opened, [], 'a late open cannot revive a cancelled guide'); + } + const neverShown = scenario === 'restricted' || scenario === 'unmappedSession' || scenario === 'noSearch' || scenario === 'blocked'; + const completes = ['accepted', 'replaced', 'rowAccepted', 'buttonAccepted', 'rowButtonAccepted', 'buttonsAccepted'].includes(scenario); + assert.strictEqual(actions.includes('guideCompleted'), completes); + assert.strictEqual(actions.filter(action => action === 'guideShown').length, neverShown ? 0 : 1); + assert.strictEqual(spotlightShows, neverShown ? 0 : scenario === 'replaced' || (startWithRow && completes) ? 2 : 1); + assert.deepStrictEqual({ rowOpens, buttonSelections }, { + rowOpens: useRowButton ? 1 : 0, + buttonSelections: ['buttonAccepted', 'buttonsAccepted', 'buttonRejected'].includes(scenario) ? [target.identifier] : [], + }); + assert.strictEqual(element.hasAttribute(ONBOARDING_TARGET_ATTR), false, 'run target is disposed'); + assert.strictEqual(foreign.hasAttribute(ONBOARDING_TARGET_ATTR), false, 'replacement target is disposed'); + assert.deepStrictEqual({ reveals, releases }, { reveals: startWithRow ? 1 : 0, releases: startWithRow ? 1 : 0 }); + }); + } + + test('waits for the exact widget owner after session opening completes', async () => { + const resource = URI.parse('agent-host-codex:/one'); + const otherResource = URI.parse('agent-host-codex:/other'); + const added = store.add(new Emitter()); + const focusedSessionChanged = store.add(new Emitter()); + const widgets: IChatWidget[] = []; + const other = upcastPartial({ + visible: true, + viewModel: upcastPartial>({ sessionResource: otherResource }), + onDidChangeViewModel: Event.None, + }); + const exact = upcastPartial({ + visible: true, + viewModel: upcastPartial>({ sessionResource: resource }), + onDidChangeViewModel: Event.None, + }); + const service = upcastPartial({ + get lastFocusedWidget() { return widgets.at(-1); }, + getAllWidgets: () => widgets, + onDidAddWidget: added.event, + onDidChangeFocusedSession: focusedSessionChanged.event, + onDidChangeWidgetVisibility: Event.None, + }); + const cancellation = store.add(new CancellationTokenSource()); + const result = waitForActiveChatWidget(resource, service, () => widgets.at(-1), cancellation.token, 1_000); + widgets.push(other); + added.fire(other); + widgets.push(exact); + added.fire(exact); + + assert.strictEqual(await result, exact); + }); + + test('cancelling the widget wait ignores a late owner', async () => { + const resource = URI.parse('agent-host-codex:/one'); + const added = store.add(new Emitter()); + const widgets: IChatWidget[] = []; + const service = upcastPartial({ + getAllWidgets: () => widgets, + onDidAddWidget: added.event, + onDidChangeFocusedSession: Event.None, + onDidChangeWidgetVisibility: Event.None, + }); + const cancellation = store.add(new CancellationTokenSource()); + const result = waitForActiveChatWidget(resource, service, () => widgets.at(-1), cancellation.token, 1_000); + cancellation.cancel(); + const exact = upcastPartial({ + visible: true, + viewModel: upcastPartial>({ sessionResource: resource }), + onDidChangeViewModel: Event.None, + }); + widgets.push(exact); + added.fire(exact); + + assert.strictEqual(await result, undefined); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/codexContinuationPresenter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/codexContinuationPresenter.test.ts new file mode 100644 index 00000000000000..ebd8fd38e32139 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/codexContinuationPresenter.test.ts @@ -0,0 +1,299 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { DeferredPromise, timeout } from '../../../../../../base/common/async.js'; +import { errorHandler, setUnexpectedErrorHandler } from '../../../../../../base/common/errors.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { observableValue } from '../../../../../../base/common/observable.js'; +import { upcastPartial } from '../../../../../../base/test/common/mock.js'; +import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { ICodexContinuationCandidate } from '../../../../../services/agentHost/browser/codexContinuation.js'; +import { ICodexContinuationService } from '../../../../../services/agentHost/browser/codexContinuationService.js'; +import { IHostService } from '../../../../../services/host/browser/host.js'; +import { CodexContinuationPresenter } from '../../../browser/agentSessions/agentHost/codexContinuationPresenter.js'; +import { ILanguageModelsService } from '../../../common/languageModels.js'; + +suite('Codex continuation presentation boundary', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + for (const surface of ['agentsWindow', 'editorWindow'] as const) { + for (const failure of ['declined', 'storage error'] as const) { + test(`${surface} retries after a ${failure} visibility claim`, () => runWithFakedTimers({}, async () => { + const changed = store.add(new Emitter()); + const candidate = upcastPartial({}); + const expectedError = new Error('Shared storage is unavailable'); + const errors: Error[] = []; + let reserved = false; + let claims = 0; + let shown = 0; + let released = 0; + let disposed = 0; + let visibleSurfaces = 0; + const nudge = upcastPartial({ + candidate: observableValue('candidate', candidate), revision: observableValue('revision', 0), setSelectableModels: () => { }, endPreview: () => { }, + wouldShow: async () => !reserved, resolve: async () => candidate, + reservePresentation: async () => { reserved = true; return true; }, + releasePresentation: async () => { reserved = false; released++; }, ownsEpisode: () => true, + markVisible: async () => { + if (++claims === 1) { + if (failure === 'storage error') { throw expectedError; } + return false; + } + return true; + }, + trackVisibility: () => { visibleSurfaces++; return toDisposable(() => visibleSurfaces--); }, + }); + let visible: () => Promise; + store.add(new CodexContinuationPresenter({ + surface, onDidChangePresentability: changed.event, isPresentable: () => true, + show: (_candidate, didShow) => { shown++; visible = didShow; return toDisposable(() => disposed++); }, + }, nudge, upcastPartial({ hasFocus: true, onDidChangeFocus: Event.None }), + upcastPartial({ getLanguageModelIds: () => [], onDidChangeLanguageModels: Event.None }))); + await timeout(1); + const originalErrorHandler = errorHandler.getUnexpectedErrorHandler(); + setUnexpectedErrorHandler(error => errors.push(error)); + try { + assert.strictEqual(await visible!(), false); + } finally { + setUnexpectedErrorHandler(originalErrorHandler); + } + changed.fire(); + await timeout(1); + assert.deepStrictEqual({ shown, released, disposed, reserved, visibleSurfaces, errors }, { + shown: 2, released: 1, disposed: 1, reserved: true, visibleSurfaces: 0, + errors: failure === 'storage error' ? [expectedError] : [], + }); + assert.strictEqual(await visible!(), true); + })); + } + + for (const scenario of ['clickBeforeVisible', 'clickDuringClaim', 'dismissDuringClaim', 'rejectedClaim'] as const) { + test(`${surface} actions await one visibility claim: ${scenario}`, () => runWithFakedTimers({}, async () => { + const pending = new DeferredPromise(); + const candidate = upcastPartial({}); + let claims = 0; + let actions = 0; + let disposed = 0; + let visibleSurfaces = 0; + const nudge = upcastPartial({ + candidate: observableValue('candidate', candidate), revision: observableValue('revision', 0), setSelectableModels: () => { }, endPreview: () => { }, + wouldShow: async () => true, resolve: async () => candidate, reservePresentation: async () => true, + releasePresentation: async () => { }, ownsEpisode: () => true, + markVisible: async (_surface, _candidate, isVisible) => { claims++; return await pending.p && isVisible!(); }, + trackVisibility: () => { visibleSurfaces++; return toDisposable(() => visibleSurfaces--); }, + }); + let visible: () => Promise; + let close: (reason: 'action' | 'dismissed') => void; + let runAction: (action: () => void) => Promise; + store.add(new CodexContinuationPresenter({ + surface, onDidChangePresentability: Event.None, isPresentable: () => true, + show: (_candidate, didShow, didClose, action) => { + visible = didShow; close = didClose; runAction = action; + return toDisposable(() => disposed++); + }, + }, nudge, upcastPartial({ hasFocus: true, onDidChangeFocus: Event.None }), + upcastPartial({ getLanguageModelIds: () => [], onDidChangeLanguageModels: Event.None }))); + await timeout(1); + if (scenario !== 'clickBeforeVisible') { void visible!(); } + const action = runAction!(() => actions++); + const duplicate = runAction!(() => actions++); + assert.deepStrictEqual({ claims, actions, disposed, visibleSurfaces }, { claims: 1, actions: 0, disposed: 0, visibleSurfaces: 0 }); + if (scenario === 'dismissDuringClaim') { close!('dismissed'); } + await pending.complete(scenario !== 'rejectedClaim'); + await Promise.all([action, duplicate]); + assert.deepStrictEqual({ claims, actions, disposed, visibleSurfaces }, { + claims: 1, actions: scenario === 'clickBeforeVisible' || scenario === 'clickDuringClaim' ? 1 : 0, disposed: 1, visibleSurfaces: 0, + }); + })); + } + + test(`${surface} ignores an old failed claim after a new presentation is visible`, () => runWithFakedTimers({}, async () => { + const pending = new DeferredPromise(); + const changed = store.add(new Emitter()); + const candidate = upcastPartial({}); + let claims = 0; + let disposed = 0; + let released = 0; + let previewEnded = 0; + let visibleSurfaces = 0; + const nudge = upcastPartial({ + candidate: observableValue('candidate', candidate), revision: observableValue('revision', 0), setSelectableModels: () => { }, + endPreview: () => previewEnded++, + wouldShow: async () => true, resolve: async () => candidate, reservePresentation: async () => true, + releasePresentation: async () => { released++; }, ownsEpisode: () => true, + markVisible: async () => ++claims === 1 ? pending.p : true, + trackVisibility: () => { visibleSurfaces++; return toDisposable(() => visibleSurfaces--); }, + }); + let visible: () => Promise; + let close: (reason: 'action' | 'dismissed') => void; + store.add(new CodexContinuationPresenter({ + surface, onDidChangePresentability: changed.event, isPresentable: () => true, + show: (_candidate, didShow, didClose) => { visible = didShow; close = didClose; return toDisposable(() => disposed++); }, + }, nudge, upcastPartial({ hasFocus: true, onDidChangeFocus: Event.None }), + upcastPartial({ getLanguageModelIds: () => [], onDidChangeLanguageModels: Event.None }))); + await timeout(1); + const firstClaim = visible!(); + close!('dismissed'); + changed.fire(); + await timeout(1); + assert.strictEqual(await visible!(), true); + const expectedError = new Error('Late storage failure'); + const errors: Error[] = []; + const originalErrorHandler = errorHandler.getUnexpectedErrorHandler(); + setUnexpectedErrorHandler(error => errors.push(error)); + try { + await pending.error(expectedError); + assert.strictEqual(await firstClaim, false); + } finally { + setUnexpectedErrorHandler(originalErrorHandler); + } + assert.deepStrictEqual({ claims, disposed, released, previewEnded, visibleSurfaces, errors }, { + claims: 2, disposed: 1, released: 1, previewEnded: 1, visibleSurfaces: 1, errors: [expectedError], + }); + })); + + test(`${surface} hidden or silenced does not trigger, reserve, or claim`, () => runWithFakedTimers({}, async () => { + const changed = store.add(new Emitter()); + let presentable = false; + const calls: string[] = []; + const candidate = upcastPartial({}); + const nudge = upcastPartial({ + candidate: observableValue('candidate', candidate), revision: observableValue('revision', 0), + trackVisibility: () => toDisposable(() => { }), + setSelectableModels: () => { }, endPreview: () => { }, + wouldShow: async () => { calls.push('trigger'); return true; }, resolve: async () => candidate, + reservePresentation: async () => { calls.push('reserve'); return true; }, + releasePresentation: async () => { }, ownsEpisode: () => true, + markVisible: async () => { calls.push('visible'); return true; }, + }); + let visible: (() => Promise) | undefined; + const presenter = store.add(new CodexContinuationPresenter({ + surface, onDidChangePresentability: changed.event, isPresentable: () => presentable, + show: (_candidate, didShow) => { calls.push('create'); visible = didShow; return toDisposable(() => { }); }, + }, nudge, upcastPartial({ hasFocus: true, onDidChangeFocus: Event.None }), + upcastPartial({ getLanguageModelIds: () => [], onDidChangeLanguageModels: Event.None }))); + await timeout(1); + assert.deepStrictEqual(calls, []); + presentable = true; + changed.fire(); + await timeout(1); + assert.deepStrictEqual(calls, ['trigger', 'reserve', 'create']); + await visible!(); + await visible!(); + assert.deepStrictEqual(calls, ['trigger', 'reserve', 'create', 'visible']); + presenter.dispose(); + assert.strictEqual(await visible!(), false); + })); + + test(`${surface} removes a visible nudge when quota eligibility is lost`, () => runWithFakedTimers({}, async () => { + const candidate = upcastPartial({}); + const eligible = observableValue('candidate', candidate); + let disposed = 0; + let dismissed = 0; + const nudge = upcastPartial({ + candidate: eligible, revision: observableValue('revision', 0), setSelectableModels: () => { }, endPreview: () => { }, + trackVisibility: () => toDisposable(() => { }), + wouldShow: async () => true, resolve: async () => eligible.get(), reservePresentation: async () => true, + releasePresentation: async () => { }, ownsEpisode: () => true, markVisible: async () => true, + dismiss: () => dismissed++, + }); + let visible: (() => Promise) | undefined; + store.add(new CodexContinuationPresenter({ + surface, onDidChangePresentability: Event.None, isPresentable: () => true, + show: (_candidate, didShow) => { visible = didShow; return toDisposable(() => disposed++); }, + }, nudge, upcastPartial({ hasFocus: true, onDidChangeFocus: Event.None }), + upcastPartial({ getLanguageModelIds: () => [], onDidChangeLanguageModels: Event.None }))); + await timeout(1); + assert.strictEqual(await visible!(), true); + + eligible.set(undefined, undefined); + await timeout(1); + + assert.deepStrictEqual({ disposed, dismissed }, { disposed: 1, dismissed: 0 }); + })); + } + test('disposal while treatment resolves prevents late UI', () => runWithFakedTimers({}, async () => { + const treatment = new DeferredPromise(); + let shown = 0; + const candidate = upcastPartial({}); + const nudge = upcastPartial({ + candidate: observableValue('candidate', candidate), revision: observableValue('revision', 0), setSelectableModels: () => { }, endPreview: () => { }, + wouldShow: () => treatment.p, resolve: async () => candidate, releasePresentation: async () => { }, + }); + const presenter = store.add(new CodexContinuationPresenter({ + surface: 'editorWindow', onDidChangePresentability: Event.None, isPresentable: () => true, + show: () => { shown++; return toDisposable(() => { }); }, + }, nudge, upcastPartial({ hasFocus: true, onDidChangeFocus: Event.None }), + upcastPartial({ getLanguageModelIds: () => [], onDidChangeLanguageModels: Event.None }))); + await timeout(1); + presenter.dispose(); + await treatment.complete(true); + await timeout(1); + assert.strictEqual(shown, 0); + })); + + test('a visibly claimed presentation survives ordinary window blur', () => runWithFakedTimers({}, async () => { + const focusChanged = store.add(new Emitter()); + let hasFocus = true; + let disposed = 0; + let dismissed = 0; + const candidate = upcastPartial({}); + const nudge = upcastPartial({ + candidate: observableValue('candidate', candidate), revision: observableValue('revision', 0), setSelectableModels: () => { }, endPreview: () => { }, + wouldShow: async () => true, resolve: async () => candidate, reservePresentation: async () => true, + trackVisibility: () => toDisposable(() => { }), + releasePresentation: async () => { }, ownsEpisode: () => true, markVisible: async () => true, + dismiss: () => dismissed++, + }); + let visible: (() => Promise) | undefined; + let close: ((reason: 'action' | 'dismissed') => void) | undefined; + store.add(new CodexContinuationPresenter({ + surface: 'editorWindow', onDidChangePresentability: Event.None, isPresentable: () => true, + show: (_candidate, didShow, didClose) => { + visible = didShow; + close = didClose; + return toDisposable(() => disposed++); + }, + }, nudge, upcastPartial({ get hasFocus() { return hasFocus; }, onDidChangeFocus: focusChanged.event }), + upcastPartial({ getLanguageModelIds: () => [], onDidChangeLanguageModels: Event.None }))); + await timeout(1); + assert.strictEqual(await visible!(), true); + hasFocus = false; + focusChanged.fire(false); + await timeout(1); + assert.deepStrictEqual({ disposed, dismissed }, { disposed: 0, dismissed: 0 }); + close!('dismissed'); + assert.deepStrictEqual({ disposed, dismissed }, { disposed: 1, dismissed: 1 }); + })); + + test('an explicit action closes without recording a passive dismissal', () => runWithFakedTimers({}, async () => { + let dismissed = 0; + const candidate = upcastPartial({}); + const nudge = upcastPartial({ + candidate: observableValue('candidate', candidate), revision: observableValue('revision', 0), setSelectableModels: () => { }, endPreview: () => { }, + trackVisibility: () => toDisposable(() => { }), + wouldShow: async () => true, resolve: async () => candidate, reservePresentation: async () => true, + releasePresentation: async () => { }, ownsEpisode: () => true, markVisible: async () => true, + dismiss: () => dismissed++, + }); + let visible: (() => Promise) | undefined; + let close: ((reason: 'action' | 'dismissed') => void) | undefined; + store.add(new CodexContinuationPresenter({ + surface: 'agentsWindow', onDidChangePresentability: Event.None, isPresentable: () => true, + show: (_candidate, didShow, didClose) => { + visible = didShow; + close = didClose; + return toDisposable(() => { }); + }, + }, nudge, upcastPartial({ hasFocus: true, onDidChangeFocus: Event.None }), + upcastPartial({ getLanguageModelIds: () => [], onDidChangeLanguageModels: Event.None }))); + await timeout(1); + assert.strictEqual(await visible!(), true); + close!('action'); + assert.strictEqual(dismissed, 0); + })); +}); 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 0425384c960ac1..ac4882832794f6 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 @@ -14,6 +14,7 @@ import { MarkdownString, type IMarkdownString } from '../../../../../../base/com import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { AgentHostAutoReplyAnswer } from '../../../../../../platform/agentHost/common/agentHostSchema.js'; import { toAgentMessageDelegationMeta } from '../../../../../../platform/agentHost/common/meta/agentMessageDelegationMeta.js'; +import { buildOpenSessionLinkUri, parseOpenSessionLinkChatId, parseOpenSessionLinkConnectionAuthority, parseOpenSessionLinkUri } from '../../../../../../platform/agentHost/common/openSessionLink.js'; import { toAgentMergeMessageMeta } from '../../../../../../platform/agentHost/common/meta/agentMergeMessageMeta.js'; import { AgentSystemNotificationKind, AgentSystemNotificationSeverity, AgentSystemNotificationWorkspaceKind, toAgentSystemNotificationMeta } from '../../../../../../platform/agentHost/common/meta/agentSystemNotificationMeta.js'; import { toAgentWorkspaceContinuationMessageMeta } from '../../../../../../platform/agentHost/common/meta/agentWorkspaceContinuationMeta.js'; @@ -662,40 +663,56 @@ suite('stateToProgressAdapter', () => { history[2].type === 'request' ? history[2].origin : undefined, ], [{ kind: ChatRequestOriginKind.Delegation, - sourceSessionResource: URI.parse('agent-host-session://copilot/creator?turn=creating-turn'), + sourceSessionResource: URI.parse(buildOpenSessionLinkUri(URI.parse('copilot:/creator'), undefined, 'creating-turn', 'local')), delegationScope: 'session', }, undefined]); }); - test('created session maps an aliased backend source to its logical provider', () => { + for (const provider of ['copilotcli', 'codex', 'claude']) { + test(`created ${provider} session retains its standard delegation source`, () => { + const turn = createTurn({ + message: { + text: 'Hello', + origin: { kind: MessageKind.User }, + _meta: toAgentMessageDelegationMeta({ + sourceSession: 'ahp-session:/creator', + sourceChat: 'ahp-chat://default/YWhwLXNlc3Npb246L2NyZWF0b3I', + }), + }, + }); + + const history = rawTurnsToHistory( + URI.parse('ahp-session:/created'), + [turn], + `agent-host-${provider}`, + 'remote', + ); + + assert.deepStrictEqual(history[0].type === 'request' ? history[0].origin : undefined, { + kind: ChatRequestOriginKind.Delegation, + sourceSessionResource: URI.parse(buildOpenSessionLinkUri(URI.parse('ahp-session:/creator'), undefined, undefined, 'remote')), + delegationScope: 'session', + }); + }); + } + + test('delegation keeps opaque source session and chat resources on their owning connection', () => { + const source = URI.parse('session-store://tenant/root?revision=2'); + const chat = URI.parse('conversation://tenant/peer?revision=1#part'); const turn = createTurn({ message: { - text: 'Hello', - origin: { kind: MessageKind.User }, - _meta: toAgentMessageDelegationMeta({ - sourceSession: 'ahp-session:/creator', - sourceChat: 'ahp-chat://default/YWhwLXNlc3Npb246L2NyZWF0b3I', - }), + text: 'Delegated', origin: { kind: MessageKind.User }, + _meta: toAgentMessageDelegationMeta({ sourceSession: source.toString(), sourceChat: chat.toString() }), }, }); - - const history = rawTurnsToHistory( - URI.parse('ahp-session:/created'), - [turn], - 'copilot', - 'remote', - undefined, - undefined, - undefined, - undefined, - 'copilot', - ); - - assert.deepStrictEqual(history[0].type === 'request' ? history[0].origin : undefined, { - kind: ChatRequestOriginKind.Delegation, - sourceSessionResource: URI.parse('agent-host-session://copilot/creator'), - delegationScope: 'session', - }); + const history = rawTurnsToHistory(URI.parse('ahp-session:/receiver'), [turn], 'agent-host-claude', 'other-host'); + const link = history[0].type === 'request' ? history[0].origin?.sourceSessionResource : undefined; + assert.ok(link); + assert.deepStrictEqual({ + source: parseOpenSessionLinkUri(link)?.toString(), + chat: URI.parse(parseOpenSessionLinkChatId(link)!).toString(), + owner: parseOpenSessionLinkConnectionAuthority(link), + }, { source: source.toString(), chat: chat.toString(), owner: 'other-host' }); }); test('thread coordination tools restore deterministic target-session chips', () => { @@ -1605,7 +1622,7 @@ suite('stateToProgressAdapter', () => { confirmationButtons: [{ data: { resume: true }, label: 'Try Again' }], }; - const history = rawTurnsToHistory(URI.file('/'), [errorTurn, completeTurn], 'p', '', undefined, undefined, undefined, createAgentHostResourceUriMapper(''), undefined, () => errorDetails); + const history = rawTurnsToHistory(URI.file('/'), [errorTurn, completeTurn], 'p', '', undefined, undefined, undefined, createAgentHostResourceUriMapper(''), () => errorDetails); const responses = history.filter(item => item.type === 'response'); assert.deepStrictEqual(responses.map(response => response.type === 'response' ? response.errorDetails : undefined), [ @@ -1679,8 +1696,300 @@ suite('stateToProgressAdapter', () => { }); }); + suite('image generation', () => { + test('preserves metadata-only image failure details and classification in live and restored state', () => { + const backendSession = URI.parse('other-host:/opaque-image-session'); + const imageGeneration = { requestedModel: { id: 'remote/image-model', name: 'Remote Image' } }; + const running = createToolCallState({ + toolName: 'remote_create_image', + toolInput: undefined, + _meta: { 'vscode.imageGeneration': imageGeneration }, + }); + const completed = createCompletedToolCall({ + ...running, + status: ToolCallStatus.Completed, + success: false, + content: [], + error: { message: 'The image service rejected the request.' }, + }); + const live = rawToolCallStateToInvocation(running, undefined, backendSession, 'other-host'); + rawFinalizeToolInvocation(live, completed, backendSession, 'other-host'); + const restored = completedToolCallToSerialized(completed, undefined, backendSession, 'other-host'); + const expected = { + title: 'Generated image failed', + data: { kind: 'input', rawInput: undefined, editable: false, imageGeneration }, + details: { + input: '', + inputLanguage: 'json', + output: [{ type: 'embed', value: 'The image service rejected the request.', isText: true, mimeType: 'text/plain' }], + isError: true, + mcpOutput: undefined, + }, + }; + assert.deepStrictEqual([live, restored].map(part => ({ + title: part.pastTenseMessage, + data: part.toolSpecificData, + details: part.kind === 'toolInvocation' ? IChatToolInvocation.resultDetails(part) : part.resultDetails, + })), [expected, expected]); + }); + + for (const streaming of [false, true]) { + test(`preserves metadata-only image cancellation in history (streaming=${streaming})`, () => { + const backendSession = URI.parse('other-host:/opaque-image-session'); + const imageGeneration = { requestedModel: { id: 'remote/image-model' } }; + const cancelled: ICompletedToolCall = { + toolCallId: 'remote-image', + toolName: 'remote_create_image', + displayName: 'Remote Image', + invocationMessage: 'Working on an image', + status: ToolCallStatus.Cancelled, + reason: ToolCallCancellationReason.Denied, + reasonMessage: 'Stopped by the user', + _meta: { 'vscode.imageGeneration': imageGeneration }, + }; + const live = streaming + ? toolCallStateToStreamingInvocation({ ...cancelled, status: ToolCallStatus.Streaming }, undefined, backendSession, 'other-host') + : rawToolCallStateToInvocation(createToolCallState({ toolName: cancelled.toolName, _meta: cancelled._meta }), undefined, backendSession, 'other-host'); + rawFinalizeToolInvocation(live, cancelled, backendSession, 'other-host'); + const restored = completedToolCallToSerialized(cancelled, undefined, backendSession, 'other-host'); + const expected = { + title: 'Image generation cancelled', + data: { kind: 'input', rawInput: undefined, editable: false, imageGeneration }, + }; + assert.deepStrictEqual([live, restored].map(part => ({ title: part.pastTenseMessage, data: part.toolSpecificData })), [expected, expected]); + }); + } + + for (const toolInput of [undefined, '']) { + test(`does not give ordinary output-only tools an empty input dropdown (input=${toolInput})`, () => { + const backendSession = URI.parse('other-host:/ordinary-session'); + const running = createToolCallState({ toolName: 'ordinary_tool', toolInput }); + const completed = createCompletedToolCall({ toolName: running.toolName, toolInput, content: [{ type: ToolResultContentType.Text, text: 'Finished waiting.' }] }); + const live = rawToolCallStateToInvocation(running, undefined, backendSession, 'other-host'); + rawFinalizeToolInvocation(live, completed, backendSession, 'other-host'); + const restored = completedToolCallToSerialized(completed, undefined, backendSession, 'other-host'); + assert.deepStrictEqual([IChatToolInvocation.resultDetails(live), restored.resultDetails], [undefined, undefined]); + }); + } + + for (const toolName of ['image_generation', 'image_gen.imagegen', 'test_tool']) { + for (const streaming of [true, false]) { + test(`preserves the cancelled ${toolName} label from ${streaming ? 'streaming' : 'running'} across live and restored history`, () => { + const backendSession = URI.parse('copilot:/cancelled-image'); + const invocationMessage = 'Running test tool...'; + const cancelled: ICompletedToolCall = { + toolCallId: 'cancelled-tool', + toolName, + displayName: 'Test Tool', + invocationMessage, + status: ToolCallStatus.Cancelled, + reason: ToolCallCancellationReason.Denied, + reasonMessage: 'Stopped by the user', + }; + const live = streaming + ? toolCallStateToStreamingInvocation({ ...cancelled, status: ToolCallStatus.Streaming }, undefined, backendSession, 'local') + : rawToolCallStateToInvocation(createToolCallState({ toolCallId: cancelled.toolCallId, toolName, invocationMessage }), undefined, backendSession, 'local'); + rawFinalizeToolInvocation(live, cancelled, backendSession, 'local'); + const restored = completedToolCallToSerialized(cancelled, undefined, backendSession, 'local'); + const message = toolName === 'test_tool' ? invocationMessage : 'Image generation cancelled'; + assert.deepStrictEqual({ + liveMessage: live.pastTenseMessage ?? live.invocationMessage, + liveComplete: IChatToolInvocation.isComplete(live), + restoredMessage: restored.pastTenseMessage, + restoredComplete: restored.isComplete, + }, { + liveMessage: message, + liveComplete: true, + restoredMessage: message, + restoredComplete: true, + }); + }); + } + } + + for (const [toolName, success] of [ + ['image_generation', true], + ['image_generation', false], + ['image_gen.imagegen', true], + ['image_gen.imagegen', false], + ] as const) { + test(`preserves the ${success ? 'completed' : 'failed'} ${toolName} label across live and restored history`, () => { + const backendSession = URI.parse('copilot:/image-state'); + const pastTenseMessage = success ? 'Generated image' : '"Generate Image" failed'; + const expectedMessage = success ? pastTenseMessage : 'Generated image failed'; + const completed = createCompletedToolCall({ + toolName, + invocationMessage: 'Generating image', + pastTenseMessage, + success, + toolInput: undefined, + content: success ? [{ type: ToolResultContentType.EmbeddedResource, data: 'aW1hZ2U=', contentType: 'image/png' }] : [], + error: success ? undefined : { message: 'Image generation returned no usable image.' }, + }); + const live = rawToolCallStateToInvocation(createToolCallState({ + toolName, + invocationMessage: 'Generating image', + }), undefined, backendSession, 'local'); + rawFinalizeToolInvocation(live, completed, backendSession, 'local'); + const restored = completedToolCallToSerialized(completed, undefined, backendSession, 'local'); + const expectedDetails = { + input: '', + inputLanguage: 'json', + output: success + ? [{ type: 'embed', value: 'aW1hZ2U=', mimeType: 'image/png' }] + : [{ type: 'embed', value: 'Image generation returned no usable image.', isText: true, mimeType: 'text/plain' }], + isError: !success, + mcpOutput: undefined, + }; + assert.deepStrictEqual({ + live: live.pastTenseMessage, + restored: restored.pastTenseMessage, + complete: restored.isComplete, + details: [IChatToolInvocation.resultDetails(live), restored.resultDetails], + }, { live: expectedMessage, restored: expectedMessage, complete: true, details: [expectedDetails, expectedDetails] }); + }); + } + + for (const name of [undefined, 'Image Preview']) { + test(`uses structured image identity with ${name ? 'a display name' : 'an id fallback'} on a non-VS Code host`, () => { + const backendSession = URI.parse('other-host:/opaque-resource'); + const expectedName = name ?? 'provider/image-preview'; + const metadata = { 'vscode.imageGeneration': { requestedModel: { id: 'provider/image-preview', name } } }; + const running = createToolCallState({ + toolName: 'remote_create_image', + _meta: metadata, + }); + const live = rawToolCallStateToInvocation(running, undefined, backendSession, 'other-host'); + const state = live.state.get(); + assert.ok(state.type === IChatToolInvocation.StateKind.Executing); + const progress = state.progress.get().message; + const completed = createCompletedToolCall({ + toolName: running.toolName, + _meta: metadata, + pastTenseMessage: 'Generated image', + content: [{ type: ToolResultContentType.EmbeddedResource, data: 'aW1hZ2U=', contentType: 'image/png' }], + }); + rawFinalizeToolInvocation(live, completed, backendSession, 'other-host'); + const restored = completedToolCallToSerialized(completed, undefined, backendSession, 'other-host'); + assert.deepStrictEqual({ + progress, + live: live.pastTenseMessage, + restored: restored.pastTenseMessage, + imageData: restored.toolSpecificData, + }, { + progress: `Generating image with ${expectedName}`, + live: `Generated image with ${expectedName}`, + restored: `Generated image with ${expectedName}`, + imageData: { kind: 'generatedImage' }, + }); + }); + } + + for (const toolName of ['image_gen.imagegen', 'image_generation']) { + for (const resourceReference of [false, true]) { + for (const toolInput of [undefined, '{"prompt":"Draw a puppy"}']) { + test(`${toolName} preserves ${resourceReference ? 'referenced' : 'embedded'} images ${toolInput ? 'with' : 'without'} input across live, reconnect, and history`, () => { + const backendSession = URI.parse('copilot:/image-session'); + const connectionAuthority = 'ssh__image-host'; + const uri = 'generated-images:/session/generated-image.png?version=1'; + const content: ToolResultContent[] = resourceReference + ? [{ type: ToolResultContentType.Resource, uri, contentType: 'image/png' }] + : [{ type: ToolResultContentType.EmbeddedResource, data: 'aW1hZ2U=', contentType: 'image/png' }]; + const completed = createCompletedToolCall({ toolName, toolInput, content }); + const responseParts: ToolCallResponsePart[] = [{ kind: ResponsePartKind.ToolCall, toolCall: completed }]; + const live = rawToolCallStateToInvocation(createToolCallState({ toolName, toolInput }), undefined, backendSession, connectionAuthority); + rawFinalizeToolInvocation(live, completed, backendSession, connectionAuthority); + const reconnected = activeTurnToProgress(backendSession, { + id: 'turn-image', + startedAt: '2026-01-01T00:00:00.000Z', + message: message('Draw a puppy'), + responseParts, + usage: undefined, + }, connectionAuthority)[0]; + const history = rawTurnsToHistory(backendSession, [createTurn({ responseParts })], 'participant', connectionAuthority)[1]; + assert.ok(reconnected.kind === 'toolInvocationSerialized' && history.type === 'response'); + const restored = history.parts[0]; + assert.ok(restored.kind === 'toolInvocationSerialized'); + + const expected = { + toolSpecificData: { kind: 'generatedImage' }, + resultDetails: { + input: toolInput ?? '', + inputLanguage: 'json', + output: resourceReference + ? [{ type: 'ref', uri: toAgentHostContentUri(URI.parse(uri), connectionAuthority), mimeType: 'image/png' }] + : [{ type: 'embed', value: 'aW1hZ2U=', mimeType: 'image/png' }], + isError: false, + mcpOutput: undefined, + }, + }; + assert.deepStrictEqual([live, reconnected, restored].map(part => ({ + toolSpecificData: part.toolSpecificData, + resultDetails: part.kind === 'toolInvocation' ? IChatToolInvocation.resultDetails(part) : part.resultDetails, + })), [expected, expected, expected]); + }); + } + } + } + + test('keeps local file image references directly accessible', () => { + const uri = 'file:///session/generated-image.png'; + const result = completedToolCallToSerialized(createCompletedToolCall({ + toolName: 'image_generation', + content: [{ type: ToolResultContentType.Resource, uri, contentType: 'image/png' }], + }), undefined, URI.file('/'), 'local'); + assertInputOutputDetails(result.resultDetails); + assert.deepStrictEqual({ + toolSpecificData: result.toolSpecificData, + output: result.resultDetails.output, + }, { + toolSpecificData: { kind: 'generatedImage' }, + output: [{ type: 'ref', uri: URI.parse(uri), mimeType: 'image/png' }], + }); + }); + + test('does not promote failed, empty, non-image, or image-viewing results to generated images', () => { + const image: ToolResultContent = { type: ToolResultContentType.EmbeddedResource, data: 'aW1hZ2U=', contentType: 'image/png' }; + const calls = [ + createCompletedToolCall({ toolName: 'image_generation', success: false, content: [image] }), + createCompletedToolCall({ toolName: 'image_generation', content: [] }), + createCompletedToolCall({ toolName: 'image_generation', content: [{ ...image, data: '' }] }), + createCompletedToolCall({ toolName: 'image_generation', content: [{ type: ToolResultContentType.Resource, uri: 'generated-images:/result', contentType: 'application/pdf' }] }), + createCompletedToolCall({ toolName: 'view_image', content: [image] }), + ]; + assert.deepStrictEqual(calls.map(call => completedToolCallToSerialized(call, undefined, URI.file('/'), 'local').toolSpecificData?.kind), ['input', 'input', 'input', 'input', undefined]); + }); + + test('keeps image tool output-only results without introducing empty details', () => { + const calls = [ + createCompletedToolCall({ toolName: 'image_generation', content: [{ type: ToolResultContentType.Text, text: 'Output without arguments' }] }), + createCompletedToolCall({ toolName: 'image_generation' }), + ]; + assert.deepStrictEqual(calls.map(call => completedToolCallToSerialized(call, undefined, URI.file('/'), 'local').resultDetails), [{ + input: '', + inputLanguage: 'json', + output: [{ type: 'embed', value: 'Output without arguments', isText: true, mimeType: 'text/plain' }], + isError: false, + mcpOutput: undefined, + }, undefined]); + }); + }); + suite('toolCallStateToInvocation', () => { + test('keeps image prompt input and image identity while running and reconnecting', () => { + const imageGeneration = { requestedModel: { id: 'provider/image-preview', name: 'Image Preview' } }; + const tc = createToolCallState({ + toolName: 'image_generation', + toolInput: '{"prompt":"A puppy","quality":"low"}', + _meta: { 'vscode.imageGeneration': imageGeneration }, + }); + const live = toolCallStateToInvocation(tc); + const expected = { kind: 'input', rawInput: tc.toolInput, editable: false, imageGeneration }; + const restoredRunning = toolCallStateToInvocation(tc); + assert.deepStrictEqual([live.toolSpecificData, restoredRunning.toolSpecificData], [expected, expected]); + }); + test('creates ChatToolInvocation for running tool', () => { const tc = createToolCallState({ toolCallId: 'tc-42', @@ -1822,6 +2131,79 @@ suite('stateToProgressAdapter', () => { assert.strictEqual(carousel.answerPresentation, 'conversation'); }); + test('does not repeat the question as the carousel message', () => { + const question = { + id: 'q1', + kind: ChatInputQuestionKind.SingleSelect, + message: 'Which test option do you prefer?', + required: true, + options: [{ id: 'a', label: 'Option A' }], + }; + const repeated = createInputRequestCarousel({ + id: 'repeated', + message: question.message, + questions: [question], + }, 'local'); + const titled = createInputRequestCarousel({ + id: 'titled', + message: question.message, + questions: [{ ...question, title: 'Test options' }], + }, 'local'); + const contextual = createInputRequestCarousel({ + id: 'contextual', + message: 'Select the best option for the cloud sandbox.', + questions: [question], + }, 'local'); + const messageOnly = createInputRequestCarousel({ + id: 'message-only', + message: question.message, + }, 'local'); + + assert.deepStrictEqual({ + repeated: repeated.message, + titled: titled.message, + contextual: typeof contextual.message === 'string' ? contextual.message : contextual.message?.value, + messageOnly: messageOnly.message, + messageOnlyTitle: messageOnly.questions[0].title, + }, { + repeated: undefined, + titled: undefined, + contextual: 'Select the best option for the cloud sandbox.', + messageOnly: undefined, + messageOnlyTitle: question.message, + }); + }); + + test('rewrites remote file links in retained question text', () => { + const message = 'Use [config](file:///workspace/config.json)?'; + const repeated = createInputRequestCarousel({ + id: 'repeated', + message, + questions: [{ + id: 'q1', + kind: ChatInputQuestionKind.Text, + message: `${message}\nSee [notes](file:///workspace/notes.md).`, + required: true, + }], + }, 'my-host'); + const messageOnly = createInputRequestCarousel({ id: 'message-only', message }, 'my-host'); + const detailedMessage = repeated.questions[0].detailedMessage; + + assert.deepStrictEqual({ + repeatedMessage: repeated.message, + repeatedTitle: repeated.questions[0].title, + repeatedDetail: typeof detailedMessage === 'string' ? detailedMessage : detailedMessage?.value, + messageOnlyMessage: messageOnly.message, + messageOnlyTitle: messageOnly.questions[0].title, + }, { + repeatedMessage: undefined, + repeatedTitle: `Use [config](${rewriteAgentHostLinkTarget('file:///workspace/config.json', 'my-host')})?`, + repeatedDetail: `See [notes](${rewriteAgentHostLinkTarget('file:///workspace/notes.md', 'my-host')}).`, + messageOnlyMessage: undefined, + messageOnlyTitle: `Use [config](${rewriteAgentHostLinkTarget('file:///workspace/config.json', 'my-host')})?`, + }); + }); + test('attaches automation result data to live and restored configureAutomation calls', () => { const content: ToolResultContent[] = [{ type: ToolResultContentType.Text, diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/syncedCustomizationBundler.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/syncedCustomizationBundler.test.ts index 8cd1f771178a6f..9ef9aa5e054795 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/syncedCustomizationBundler.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/syncedCustomizationBundler.test.ts @@ -10,7 +10,7 @@ import { isCancellationError } from '../../../../../../base/common/errors.js'; import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; import { ResourceSet } from '../../../../../../base/common/map.js'; import { Schemas } from '../../../../../../base/common/network.js'; -import { isEqual } from '../../../../../../base/common/resources.js'; +import { dirname, isEqual } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { VSBuffer } from '../../../../../../base/common/buffer.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; @@ -30,6 +30,7 @@ class TestInMemoryFileSystemProvider extends InMemoryFileSystemProvider { private readonly statFailures = new ResourceSet(); private readonly writeFailures = new ResourceSet(); private statDelay = 0; + private statBlock: { directory: URI; released: Promise } | undefined; activeStats = 0; maxActiveStats = 0; statCalls = 0; @@ -43,6 +44,10 @@ class TestInMemoryFileSystemProvider extends InMemoryFileSystemProvider { this.statDelay = delay; } + blockStatsInDirectory(directory: URI, released: Promise): void { + this.statBlock = { directory, released }; + } + failStat(resource: URI): void { this.statFailures.add(resource); } @@ -59,6 +64,9 @@ class TestInMemoryFileSystemProvider extends InMemoryFileSystemProvider { if (this.statDelay > 0) { await timeout(this.statDelay); } + if (this.statBlock && isEqual(dirname(resource), this.statBlock.directory)) { + await this.statBlock.released; + } if (this.statFailures.has(resource)) { throw new Error('Unavailable test resource'); } @@ -397,7 +405,8 @@ suite('SyncedCustomizationBundler', () => { await seedFile(`/skills/replaced/references/${index}.md`, `reference ${index}`); } const replacement = await seedFile('/replacement.md', 'replacement content'); - memFs.delayStats(50); + const statsReleased = new DeferredPromise(); + memFs.blockStatsInDirectory(URI.joinPath(dirname(skill), 'references'), statsReleased.p); const disposedBundle = disposedBundler.bundle([{ uri: skill, type: PromptsType.skill }]); while (memFs.maxActiveStats < 10) { @@ -407,7 +416,9 @@ suite('SyncedCustomizationBundler', () => { const replacementBundle = replacementBundler.bundle([{ uri: replacement, type: PromptsType.instructions }]); await timeout(0); - assert.strictEqual(memFs.activeStats, 10); + const activeStats = memFs.activeStats; + statsReleased.complete(); + assert.strictEqual(activeStats, 10); await assert.rejects(disposedBundle, error => isCancellationError(error)); await replacementBundle; assert.strictEqual( diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDiscoveryPage.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDiscoveryPage.test.ts index e1640ea5c7d35a..082737eefef86e 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDiscoveryPage.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationDiscoveryPage.test.ts @@ -545,7 +545,16 @@ suite('AICustomizationDiscoveryPage', () => { const placeholder = fixture.container.querySelector('.customization-discovery-result-content.loading')!; assert.ok(placeholder.inert); placeholder.querySelector('button')!.click(); - assert.deepStrictEqual(fixture.openedDetails, []); + const spinner = placeholder.querySelector('.customization-discovery-result-loading')!; + assert.deepStrictEqual({ + openedDetails: fixture.openedDetails, + spinnerDisplay: DOM.getWindow(spinner).getComputedStyle(spinner).display, + spinnerAriaHidden: spinner.getAttribute('aria-hidden'), + }, { + openedDetails: [], + spinnerDisplay: 'grid', + spinnerAriaHidden: 'true', + }); const pending = position(); await fixture.requests[1].result.complete({ items: [resource('mail-24', { mediaType: CustomizationMarketplaceMediaType.CopilotPlugin })], @@ -557,10 +566,13 @@ suite('AICustomizationDiscoveryPage', () => { length: list.length, state: loadingState(fixture.container), busy: fixture.container.querySelector('.customization-discovery-results')?.getAttribute('aria-busy'), + visibleSpinners: [...fixture.container.querySelectorAll('.customization-discovery-result-loading')] + .filter(spinner => DOM.getWindow(spinner).getComputedStyle(spinner).display !== 'none').length, }, { position: pending, rowRetained: true, length: 25, state: { progress: false, placeholder: false, status: ['', ''] }, busy: 'false', + visibleSpinners: 0, }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts index 93e29ef6bd9351..0e3375dee7f8d4 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts @@ -249,7 +249,7 @@ suite('aiCustomizationManagementEditor', () => { }; notificationService: { error(message: string): void; info(message: string): void; warn(message: string): void }; fileDialogService: { showOpenDialog(): Promise }; - showEmbeddedEditor(uri: URI, displayName: string, promptType: PromptsType, source: AICustomizationSource, isWorkspaceFile?: boolean, isReadOnly?: boolean): Promise; + showEmbeddedEditor(uri: URI, displayName: string, promptType: PromptsType, source: AICustomizationSource, isWorkspaceFile?: boolean, isReadOnly?: boolean, migrationDetail?: { readonly item: ICustomizationMigrationDashboardItem; readonly storage: PromptsStorage }): Promise; getActiveHarnessLabel(): string; welcomePage: { setMigrationCategories(categories: readonly unknown[]): void } | undefined; selectedSection: AICustomizationManagementSection | undefined; @@ -280,7 +280,7 @@ suite('aiCustomizationManagementEditor', () => { cancelCustomizationMigrationRefresh(): void; registerCustomizationMigrationSessionRefresh(): void; renderCustomizationMigrationDashboardState(): void; - showEmbeddedMcpDetail(server: IMcpServerDetailInput): Promise; + showEmbeddedMcpDetail(server: IMcpServerDetailInput, origin?: { readonly kind: 'migration' }, migrationDetail?: { readonly item: ICustomizationMigrationDashboardItem; readonly storage: PromptsStorage }): Promise; openMigrationCustomization(item: ICustomizationMigrationDashboardItem, storage: PromptsStorage): Promise; getConfiguredLocationSettingsToClear(category: ICustomizationMigrationCategory, customizations: readonly MigratableConfiguration[]): readonly string[]; getFileMigrationConfirmationDetail(detail: string, files: readonly MigratableConfiguration[], targetFolders: CustomizationMigrationTargetFolders): string; @@ -2375,12 +2375,14 @@ suite('aiCustomizationManagementEditor', () => { test('opens dashboard customizations in their embedded editor or detail page', async () => { const editor = createTestEditor(); - const opened: [URI, string, PromptsType, AICustomizationSource, boolean | undefined][] = []; - const mcpDetails: IMcpServerDetailInput[] = []; - editor.showEmbeddedEditor = async (uri, displayName, promptType, source, isWorkspaceFile) => { - opened.push([uri, displayName, promptType, source, isWorkspaceFile]); + const opened: [URI, string, PromptsType, AICustomizationSource, boolean | undefined, string | undefined][] = []; + const mcpDetails: { readonly detail: IMcpServerDetailInput; readonly migrationItemId?: string; readonly storage?: PromptsStorage }[] = []; + editor.showEmbeddedEditor = async (uri, displayName, promptType, source, isWorkspaceFile, _isReadOnly, migrationDetail) => { + opened.push([uri, displayName, promptType, source, isWorkspaceFile, migrationDetail?.item.id]); + }; + editor.showEmbeddedMcpDetail = async (detail, _origin, migrationDetail) => { + mcpDetails.push({ detail, migrationItemId: migrationDetail?.item.id, storage: migrationDetail?.storage }); }; - editor.showEmbeddedMcpDetail = async server => { mcpDetails.push(server); }; const file = URI.file('/profile/review.prompt.md'); const mcp = URI.file('/workspace/.vscode/mcp.json'); @@ -2408,16 +2410,19 @@ suite('aiCustomizationManagementEditor', () => { args[2], args[3], args[4], + args[5], ]), - mcp: mcpDetails.map(detail => ({ + mcp: mcpDetails.map(({ detail, migrationItemId, storage }) => ({ id: detail.id, name: detail.name, compatibilityId: detail.compatibilityId, source: detail.source?.uri.path, + migrationItemId, + storage, })), }, { - file: [['/profile/review.prompt.md', 'review', PromptsType.prompt, PromptsStorage.user, false]], - mcp: [{ id: 'server', name: 'server', compatibilityId: 'server', source: '/workspace/.vscode/mcp.json' }], + file: [['/profile/review.prompt.md', 'review', PromptsType.prompt, PromptsStorage.user, false, 'file']], + mcp: [{ id: 'server', name: 'server', compatibilityId: 'server', source: '/workspace/.vscode/mcp.json', migrationItemId: 'mcp', storage: PromptsStorage.local }], }); editor.editorPreviewDisposables.dispose(); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/embeddedMcpServerDetail.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/embeddedMcpServerDetail.test.ts index 9843d34457476e..88e83c1fbd66ab 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/embeddedMcpServerDetail.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/embeddedMcpServerDetail.test.ts @@ -16,7 +16,9 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/ import { CodeEditorWidget } from '../../../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; import { Range } from '../../../../../../editor/common/core/range.js'; import { ITextModel } from '../../../../../../editor/common/model.js'; +import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; import { IFileContent, IFileService } from '../../../../../../platform/files/common/files.js'; +import { McpServerType } from '../../../../../../platform/mcp/common/mcpPlatformTypes.js'; import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; import { IMcpWorkbenchService, McpServerInstallState } from '../../../../mcp/common/mcpTypes.js'; import { EmbeddedMcpServerDetail, IMcpServerDetailInput } from '../../../browser/aiCustomization/embeddedMcpServerDetail.js'; @@ -31,7 +33,11 @@ suite('EmbeddedMcpServerDetail', () => { function createDetail() { const instantiationService = workbenchInstantiationService(undefined, store); const reads: { uri: URI; result: DeferredPromise }[] = []; + const executedCommands: unknown[][] = []; let model: ITextModel | null = null; + instantiationService.stub(ICommandService, { + executeCommand: async (...args: unknown[]) => { executedCommands.push(args); return undefined; }, + }); instantiationService.stub(IFileService, { readFile: resource => { const result = new DeferredPromise(); @@ -69,7 +75,7 @@ suite('EmbeddedMcpServerDetail', () => { const complete = (index: number, text = content) => reads[index].result.complete(new class extends mock() { override readonly value = VSBuffer.fromString(text); }()); - return { detail, input, reads, snapshot, complete }; + return { detail, input, reads, snapshot, complete, executedCommands }; } test('loads only the selected definition after migration metadata changes during the read', async () => { @@ -152,4 +158,135 @@ suite('EmbeddedMcpServerDetail', () => { editorVisible: false, }); }); + + test('shows where a server comes from when it has no configuration file', async () => { + const { detail, input, complete } = createDetail(); + const opened: string[] = []; + const path = detail.element.querySelector('.editor-item-path')!; + const editConfiguration = detail.element.querySelector('.embedded-detail-title-actions .monaco-button')!; + const header = () => ({ + label: path.style.display === 'none' ? undefined : path.textContent, + link: path.hasAttribute('href'), + ariaLabel: path.getAttribute('aria-label'), + provenance: path.classList.contains('provenance'), + editConfiguration: editConfiguration.style.display !== 'none', + }); + const builtin = { label: 'Built-in: Copilot' }; + const extension = { label: 'Built-in: GitHub Copilot Chat', ariaLabel: 'Open extension details for GitHub Copilot Chat', open: () => opened.push('extension') }; + + detail.setInput({ ...input, source: undefined, provenance: builtin }); + const agent = header(); + detail.setInput({ ...input, source: undefined, provenance: extension }); + const contributed = header(); + path.click(); + detail.setInput({ ...input, source: undefined }); + const unknown = header(); + detail.setInput({ ...input, provenance: builtin }); + const file = header(); + await complete(0); + + assert.deepStrictEqual({ agent, contributed, unknown, file, opened }, { + agent: { label: 'Built-in: Copilot', link: false, ariaLabel: null, provenance: true, editConfiguration: false }, + contributed: { label: 'Built-in: GitHub Copilot Chat', link: true, ariaLabel: 'Open extension details for GitHub Copilot Chat', provenance: true, editConfiguration: false }, + unknown: { label: undefined, link: false, ariaLabel: null, provenance: false, editConfiguration: false }, + file: { label: 'mcp-config.json', link: true, ariaLabel: 'Open mcp-config.json', provenance: false, editConfiguration: true }, + opened: ['extension'], + }); + }); + + test('explains a missing definition and links to the setting that controls the server', () => { + const { detail, input, snapshot, executedCommands } = createDetail(); + const empty = detail.element.querySelector('.mcp-detail-definition-empty')!; + const settingsLink = () => empty.querySelector('.mcp-detail-definition-settings-link'); + const read = () => ({ message: empty.querySelector('.mcp-detail-definition-message')?.textContent, link: settingsLink()?.textContent, ariaLabel: settingsLink()?.getAttribute('aria-label'), editorVisible: snapshot().editorVisible }); + const builtin = { id: 'github', name: 'github-mcp-server', label: 'github-mcp-server', installState: McpServerInstallState.Installed }; + const message = 'Copilot configures this server automatically, so its definition can\'t be viewed or edited.'; + + detail.setInput({ ...builtin, definitionUnavailable: { message, settingId: 'chat.agentHost.githubMcpServer.enabled' } }); + const withSetting = read(); + settingsLink()!.click(); + detail.setInput({ ...builtin, definitionUnavailable: { message } }); + const withoutSetting = read(); + detail.setInput({ ...builtin }); + const unexplained = read(); + detail.setInput({ ...input, source: undefined, config: { type: McpServerType.LOCAL, command: 'memory-server' }, definitionUnavailable: { message, settingId: 'chat.agentHost.githubMcpServer.enabled' } }); + + assert.deepStrictEqual({ withSetting, withoutSetting, unexplained, withDefinition: read(), executedCommands }, { + withSetting: { message, link: 'Open Settings', ariaLabel: 'Open Settings for github-mcp-server', editorVisible: false }, + withoutSetting: { message, link: undefined, ariaLabel: undefined, editorVisible: false }, + unexplained: { message: 'No definition is available for this MCP server.', link: undefined, ariaLabel: undefined, editorVisible: false }, + withDefinition: { message: 'No definition is available for this MCP server.', link: undefined, ariaLabel: undefined, editorVisible: true }, + executedCommands: [['workbench.action.openSettings', '@id:chat.agentHost.githubMcpServer.enabled']], + }); + }); + + test('updates an open detail when the host enriches a restored server, keeping migration state', () => { + const { detail } = createDetail(); + const name = () => detail.element.querySelector('.editor-item-name')?.textContent; + const path = () => detail.element.querySelector('.editor-item-path')?.textContent; + const message = () => detail.element.querySelector('.mcp-detail-definition-message')?.textContent; + const settingsLink = () => detail.element.querySelector('.mcp-detail-definition-settings-link')?.textContent; + const read = () => ({ name: name(), path: path(), message: message(), settingsLink: settingsLink() }); + const restored: IMcpServerDetailInput = { + id: 'session:server-7', name: 'github-copilot-connector-1', label: 'github-copilot-connector-1', + installState: McpServerInstallState.Installed, + }; + const enriched: IMcpServerDetailInput = { + ...restored, + label: 'Linear', + provenance: { label: 'Managed by Copilot' }, + definitionUnavailable: { message: 'Copilot manages this server, so its definition can\'t be viewed or edited.', settingId: 'chat.example.enabled' }, + }; + + detail.setInput(restored); + detail.setMigratable(true); + const before = read(); + const definitionEmpty = detail.element.querySelector('.mcp-detail-definition-message'); + detail.updateInput({ ...enriched, id: 'session:other-server', label: 'Other' }); + const otherServer = read(); + detail.updateInput(enriched); + const after = read(); + const migration = detail.element.querySelector('.mcp-detail-diagnostic-card.migration')?.closest('section')?.style.display; + const renderedMessage = detail.element.querySelector('.mcp-detail-definition-message'); + detail.updateInput({ ...enriched, provenance: { label: 'Managed by Copilot' }, error: undefined }); + + assert.deepStrictEqual({ + before, + otherServer, + after, + migrationKept: migration !== 'none', + rerendered: definitionEmpty !== renderedMessage, + unchangedKeepsNodes: detail.element.querySelector('.mcp-detail-definition-message') === renderedMessage, + }, { + before: { name: 'github-copilot-connector-1', path: '', message: 'No definition is available for this MCP server.', settingsLink: undefined }, + otherServer: { name: 'github-copilot-connector-1', path: '', message: 'No definition is available for this MCP server.', settingsLink: undefined }, + after: { name: 'Linear', path: 'Managed by Copilot', message: 'Copilot manages this server, so its definition can\'t be viewed or edited.', settingsLink: 'Open Settings' }, + migrationKept: true, + rerendered: true, + unchangedKeepsNodes: true, + }); + }); + + test('shows a host configuration that arrives after the detail opened', () => { + const { detail, snapshot } = createDetail(); + const read = () => ({ ...snapshot(), path: detail.element.querySelector('.editor-item-path')?.textContent }); + const restored: IMcpServerDetailInput = { + id: 'session:server-8', name: 'my-mcp-server', label: 'my-mcp-server', + installState: McpServerInstallState.Installed, + }; + + detail.setInput(restored); + const before = read(); + detail.updateInput({ ...restored, provenance: { label: 'Agent host configuration' }, config: { type: McpServerType.LOCAL, command: 'my-mcp-server' } }); + + assert.deepStrictEqual({ before, after: read() }, { + before: { definition: undefined, emptyMessage: 'No definition is available for this MCP server.', editorVisible: false, path: '' }, + after: { + definition: '{\n\t"servers": {\n\t\t"my-mcp-server": {\n\t\t\t"type": "stdio",\n\t\t\t"command": "my-mcp-server"\n\t\t}\n\t}\n}\n', + emptyMessage: 'No definition is available for this MCP server.', + editorVisible: true, + path: 'Agent host configuration', + }, + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts index 47b49bcbadb471..e91b9cb293c1ee 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts @@ -30,6 +30,7 @@ import { IThemeService } from '../../../../../../platform/theme/common/themeServ import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; import { mcpAccessConfig, McpAccessValue } from '../../../../../../platform/mcp/common/mcpManagement.js'; +import { McpServerType } from '../../../../../../platform/mcp/common/mcpPlatformTypes.js'; import { IExtensionsWorkbenchService } from '../../../../extensions/common/extensions.js'; import { IAuthenticationQueryService } from '../../../../../services/authentication/common/authenticationQuery.js'; import { IAuthenticationService } from '../../../../../services/authentication/common/authentication.js'; @@ -42,6 +43,7 @@ import { IAgentHostCustomizationService } from '../../../browser/agentSessions/a import { IAgentPluginService } from '../../../common/plugins/agentPluginService.js'; import { IMcpServer, IMcpService, IMcpWorkbenchService, IMcpSamplingService, IWorkbenchMcpServer, MCP_PLUGIN_COLLECTION_ID_PREFIX, McpCollectionDefinition, McpCollectionProvenance, McpConnectionState, McpServerDefinition, McpServerInstallState, McpServerTransportType } from '../../../../mcp/common/mcpTypes.js'; import { DisableMcpServerForWorkspaceAction, DisableMcpServerGloballyAction, EnableMcpServerForWorkspaceAction, EnableMcpServerGloballyAction } from '../../../../mcp/browser/mcpServerActions.js'; +import { McpCommandIds } from '../../../../mcp/common/mcpCommandIds.js'; import { AgentHostMcpServer, authenticateMcpServer, @@ -49,6 +51,8 @@ import { createInstalledMcpServerDetailInput, getActiveSessionServerLifecycleAction, getActiveSessionServerPresentation, + getActiveSessionServerDefinitionUnavailable, + getActiveSessionServerProvenance, getBuiltinMcpServerEnablementActions, getActiveSessionServerOptionsActions, getAgentHostMcpServerEnablementActions, @@ -455,19 +459,35 @@ suite('mcpListWidget', () => { type: 'server-item', server: { id: scope, local: { scope } as IWorkbenchLocalMcpServer } as IWorkbenchMcpServer, }); + const connector: ICopilotConnector = { + name: 'kusto', + displayName: 'Azure Data Explorer', + description: 'Query Azure Data Explorer.', + tags: [], + keywords: [], + capabilities: [], + representativeQueries: [], + connectionStatus: 'connected', + scopes: [], + mcpServers: [{ name: 'kusto', type: 'http' }], + }; assert.deepStrictEqual([ getMcpEntryGroup(localEntry(LocalMcpServerScope.User)), getMcpEntryGroup(localEntry(LocalMcpServerScope.Workspace)), getMcpEntryGroup({ type: 'builtin-item', id: 'plugin', label: 'Plugin', description: '', collectionId: `${MCP_PLUGIN_COLLECTION_ID_PREFIX}plugin` }), + getMcpEntryGroup({ type: 'builtin-item', id: 'connector', label: 'kusto', description: '', connector: { id: 'kusto:kusto', connector, serverName: 'kusto' } }), getMcpEntryGroup({ type: 'builtin-item', id: 'extension', label: 'Extension', description: '', extensionId: new ExtensionIdentifier('publisher.extension') }), + getMcpEntryGroup({ type: 'builtin-item', id: 'copilot', label: 'GitHub', description: '', extensionId: new ExtensionIdentifier('GitHub.copilot-chat') }), getMcpEntryGroup(createBuiltinActiveSessionMcpEntries([createAgentHostServer()])[0]), ], [ 'user', 'workspace', 'plugins', + 'plugins', 'extensions', 'builtin', + 'builtin', ]); }); @@ -556,6 +576,7 @@ suite('mcpListWidget', () => { createAgentHostServer({ name: 'plugin-server', source: 'plugin' }), createAgentHostServer({ name: 'github-mcp-server', source: 'builtin' }), createAgentHostServer({ name: 'managed-server', source: 'managed' }), + createAgentHostServer({ name: 'my-mcp-server-618d857f', hostConfiguration: { type: McpServerType.REMOTE, url: 'http://localhost:2134' } }), createAgentHostServer({ name: 'legacy-server' }), ]; @@ -568,6 +589,7 @@ suite('mcpListWidget', () => { { name: 'plugin-server', group: 'plugins' }, { name: 'github-mcp-server', group: 'builtin' }, { name: 'managed-server', group: 'builtin' }, + { name: 'my-mcp-server-618d857f', group: 'user' }, { name: 'legacy-server', group: 'builtin' }, ]); }); @@ -787,6 +809,91 @@ suite('mcpListWidget', () => { }); }); + test('describes host-only MCP servers without a configuration file by their runtime source', () => { + const provenance = (overrides: Partial) => getActiveSessionServerProvenance(createAgentHostServer(overrides), 'Copilot')?.label; + const hostConfiguration = { type: McpServerType.REMOTE, url: 'http://localhost:2134' } as const; + + assert.deepStrictEqual([ + provenance({ source: 'builtin' }), + provenance({ source: 'builtin', sourcePluginName: 'computer-use' }), + provenance({ source: 'managed', displayName: 'Linear' }), + provenance({ source: 'plugin', sourcePluginName: 'acme' }), + provenance({ source: 'plugin' }), + provenance({ source: 'user' }), + provenance({ source: 'workspace' }), + provenance({ hostConfiguration }), + provenance({ source: 'managed', hostConfiguration }), + provenance({}), + ], [ + 'Built-in: Copilot', + 'Built-in plugin: computer-use', + 'Managed by Copilot', + 'Plugin: acme', + undefined, + 'User configuration', + 'Workspace configuration', + 'Agent host configuration', + 'Managed by Copilot', + undefined, + ]); + }); + + test('shows the agent host definition of servers configured in the agent host', () => { + const hostConfiguration = { type: McpServerType.REMOTE, url: 'http://localhost:2134' } as const; + const detail = createInstalledMcpServerDetailInput(createBuiltinActiveSessionMcpEntries([ + createAgentHostServer({ name: 'my-mcp-server-618d857f', hostConfiguration }), + ])[0]); + + assert.deepStrictEqual({ name: detail.name, config: detail.config, source: detail.source }, { + name: 'my-mcp-server-618d857f', + config: hostConfiguration, + source: undefined, + }); + }); + + test('keeps the configuration key as the detail name when the host reports a display name', () => { + const provenance = { label: 'Managed by Copilot' }; + const definitionUnavailable = { message: 'Copilot manages this server, so its definition can\'t be viewed or edited.' }; + const detail = createInstalledMcpServerDetailInput({ + type: 'session-server-item', + server: createAgentHostServer({ name: 'github-copilot-connector-1', displayName: 'Linear', source: 'managed' }), + }, undefined, { provenance, definitionUnavailable }); + + assert.deepStrictEqual({ name: detail.name, label: detail.label, source: detail.source, provenance: detail.provenance, definitionUnavailable: detail.definitionUnavailable }, { + name: 'github-copilot-connector-1', + label: 'Linear (Connector)', + source: undefined, + provenance, + definitionUnavailable, + }); + }); + + test('explains why the agent\'s own MCP servers have no definition and links only a setting the host declares', () => { + const explain = (overrides: Partial) => getActiveSessionServerDefinitionUnavailable(createAgentHostServer(overrides), 'Copilot'); + const builtinMessage = 'Copilot configures this server automatically, so its definition can\'t be viewed or edited.'; + + assert.deepStrictEqual([ + explain({ name: 'github-mcp-server', source: 'builtin', controllingSettingId: 'chat.agentHost.githubMcpServer.enabled' }), + // Another conforming host can publish a built-in server with the same name that VS Code's setting cannot control. + explain({ name: 'github-mcp-server', source: 'builtin' }), + explain({ name: 'computer-use', source: 'builtin', sourcePluginName: 'computer-use' }), + explain({ name: 'github-copilot-connector-1', source: 'managed', displayName: 'Linear' }), + explain({ name: 'github-mcp-server', source: 'user', controllingSettingId: 'chat.agentHost.githubMcpServer.enabled' }), + explain({ name: 'workspace-server', source: 'workspace' }), + explain({ name: 'plugin-server', source: 'plugin', sourcePluginName: 'acme' }), + explain({ name: 'github-mcp-server' }), + ], [ + { message: builtinMessage, settingId: 'chat.agentHost.githubMcpServer.enabled' }, + { message: builtinMessage }, + { message: builtinMessage }, + { message: 'Copilot manages this server, so its definition can\'t be viewed or edited.' }, + undefined, + undefined, + undefined, + undefined, + ]); + }); + test('toggles MCP enablement without changing its scope', () => { assert.deepStrictEqual([ getToggledMcpEnablementState(ContributionEnablementState.EnabledProfile), @@ -1080,14 +1187,14 @@ suite('mcpListWidget', () => { }); test('distinguishes membership changes from state-only changes', () => { - const getMembershipSignature = (sourceUri: URI, source?: AgentHostMcpServer['source']) => { + const getMembershipSignature = (sourceUri: URI | undefined, source?: AgentHostMcpServer['source'], displayName?: string) => { const widget = Object.create(McpListWidget.prototype); Object.assign(widget, { installedEntries: [{ entry: { type: 'session-server-item', id: 'server-1', - server: createAgentHostServer({ sourceUri, source }), + server: createAgentHostServer({ sourceUri, source, displayName }), }, }], }); @@ -1098,7 +1205,8 @@ suite('mcpListWidget', () => { hasSameMcpMembership('server:one:session', 'server:one:session|server:two:session'), hasSameMcpMembership(getMembershipSignature(URI.file('/workspace/old.json')), getMembershipSignature(URI.file('/workspace/new.json'))), hasSameMcpMembership(getMembershipSignature(URI.file('/config.json')), getMembershipSignature(URI.file('/config.json'), 'user')), - ], [true, false, false, false]); + hasSameMcpMembership(getMembershipSignature(undefined, 'managed'), getMembershipSignature(undefined, 'managed', 'Linear')), + ], [true, false, false, false, false]); }); test('renders host-published disabled reasons without changing legacy rows', () => { @@ -1582,6 +1690,7 @@ suite('mcpListWidget', () => { const managementClicks: string[] = []; const openedPlugins: string[] = []; const openedExtensions: string[] = []; + const executedCommands: unknown[][] = []; let migrationRequests = 0; const hostEnablementCalls: Parameters[] = []; const runtimeServers = observableValue('runtimeServers', []); @@ -1606,6 +1715,7 @@ suite('mcpListWidget', () => { } as unknown as IAgentHostCustomizationService; const customizationHarnessService = { activeSessionResource, + getActiveDescriptor: () => ({ label: 'Copilot' }), } as unknown as ICustomizationHarnessService; const marketplaceInstallService = new class extends mock() { override readonly onDidChange = Event.None; @@ -1632,6 +1742,9 @@ suite('mcpListWidget', () => { local: [{ identifier: { id: 'publisher.extension' }, displayName: 'Example Extension', + }, { + identifier: { id: 'GitHub.copilot-chat' }, + displayName: 'GitHub Copilot Chat', }], open: async (extensionId: string) => { openedExtensions.push(extensionId); }, } as unknown as IExtensionsWorkbenchService; @@ -1684,6 +1797,8 @@ suite('mcpListWidget', () => { cardListControllers: Map; appendInstalledServerRow(parent: HTMLElement, presentation: { entry: Entry }): void; createInstalledMcpServerDetailInput(entry: Entry): ReturnType; + getUpdatedServerDetail(id: string): ReturnType | undefined; + installedEntries: { entry: Entry }[]; getMcpEntryAriaLabel(entry: Entry): IObservable; getMcpServerActions(entry: Entry, store: DisposableStore): IAction[]; renderMcpListActions(getEntry: () => Entry | undefined, actions: HTMLElement, store: DisposableStore, updateTabbability: () => void): void; @@ -1716,12 +1831,17 @@ suite('mcpListWidget', () => { managementClicks, openedPlugins, openedExtensions, + executedCommands, migrationRequests: () => migrationRequests, hostEnablementCalls, localEnablementCalls: () => localEnablementCalls, menuActions: () => menuActions, activeSessionResource, detailInput: (entry: Entry) => widget.createInstalledMcpServerDetailInput(entry), + updatedDetail: (entries: readonly Entry[], id: string) => { + widget.installedEntries = entries.map(entry => ({ entry })); + return widget.getUpdatedServerDetail(id); + }, setAriaProvider: (provider: (entry: Entry) => IObservable) => { widget.getMcpEntryAriaLabel = provider; }, setRuntimeServers: (next: readonly IMcpServer[]) => runtimeServers.set(next, undefined), menu: (entry: Entry, localServer?: IMcpServer) => { @@ -1748,7 +1868,7 @@ suite('mcpListWidget', () => { }); Object.assign(widget, { instantiationService, mcpService, mcpWorkbenchService, agentPluginService, - commandService: { executeCommand: async () => undefined }, + commandService: { executeCommand: async (...args: unknown[]) => { executedCommands.push(args); } }, workspaceService: { isSessionsWindow, getActiveProjectRoot: () => undefined }, outputService: { showChannel: async () => { } }, }); @@ -2010,6 +2130,127 @@ suite('mcpListWidget', () => { }); }); + function builtinLocalServer(origin?: URI): IMcpServer { + const detailServer = createMcpDetailTestServer(origin); + return { + ...detailServer, + definition: detailServer.readDefinitions().get().server, + collection: { id: 'collection-1', label: 'Collection', order: 0 }, + enablement: observableValue('enablement', ContributionEnablementState.EnabledProfile), + connectionState: observableValue('connectionState', { state: McpConnectionState.Kind.Running }), + } as IMcpServer; + } + + test('shows Copilot as the built-in source of its MCP servers in the row and the detail', () => { + const ctx = createRenderer(createAgentHostServer(), false); + disposables.add(ctx.store); + const entry: Entry = { + type: 'builtin-item', + id: 'github', + label: 'GitHub', + description: '', + extensionId: new ExtensionIdentifier('GitHub.copilot-chat'), + localServer: builtinLocalServer(), + }; + ctx.render(entry); + ctx.templateData.sourcePath.click(); + const detail = ctx.detailInput(entry); + detail.provenance?.open?.(); + + assert.deepStrictEqual({ + group: getMcpEntryGroup(entry), + source: ctx.readSource(), + ariaLabel: ctx.read().ariaLabel, + detail: { source: detail.source, label: detail.provenance?.label, ariaLabel: detail.provenance?.ariaLabel }, + openedExtensions: ctx.openedExtensions, + }, { + group: 'builtin', + source: { + label: 'Built-in: GitHub Copilot Chat', + hover: 'Open extension details for GitHub Copilot Chat', + tagName: 'A', + ariaLabel: 'Open extension details for GitHub Copilot Chat', + tabIndex: 0, + }, + ariaLabel: 'GitHub, configured in Built-in: GitHub Copilot Chat', + detail: { source: undefined, label: 'Built-in: GitHub Copilot Chat', ariaLabel: 'Open extension details for GitHub Copilot Chat' }, + openedExtensions: ['GitHub.copilot-chat', 'GitHub.copilot-chat'], + }); + }); + + test('rebuilds an open detail from the latest host metadata after the inventory enriches a restored server', () => { + const restored = createAgentHostServer({ id: 'server-7', name: 'github-copilot-connector-1' }); + const ctx = createRenderer(restored, false); + disposables.add(ctx.store); + const opened = ctx.detailInput(createBuiltinActiveSessionMcpEntries([restored])[0]); + const enrichedServer = createAgentHostServer({ id: 'server-7', name: 'github-copilot-connector-1', displayName: 'Linear', source: 'managed', controllingSettingId: 'chat.example.enabled' }); + const updated = ctx.updatedDetail(createBuiltinActiveSessionMcpEntries([enrichedServer]), opened.id); + const removed = ctx.updatedDetail([], opened.id); + + assert.deepStrictEqual({ + opened: { id: opened.id, label: opened.label, provenance: opened.provenance, definitionUnavailable: opened.definitionUnavailable }, + updated: updated && { id: updated.id, name: updated.name, label: updated.label, provenance: updated.provenance, definitionUnavailable: updated.definitionUnavailable }, + removed, + }, { + opened: { id: 'session:server-7', label: 'github-copilot-connector-1', provenance: undefined, definitionUnavailable: undefined }, + updated: { + id: 'session:server-7', + name: 'github-copilot-connector-1', + label: 'Linear (Connector)', + provenance: { label: 'Managed by Copilot' }, + definitionUnavailable: { message: 'Copilot manages this server, so its definition can\'t be viewed or edited.', settingId: 'chat.example.enabled' }, + }, + removed: undefined, + }); + }); + + test('shows connector names on runtime-only rows and their runtime source in the detail', () => { + const server = createAgentHostServer({ name: 'github-copilot-connector-1', displayName: 'Linear', source: 'managed' }); + const ctx = createRenderer(server, false); + disposables.add(ctx.store); + const entry = createBuiltinActiveSessionMcpEntries([server])[0]; + ctx.render(entry); + const detail = ctx.detailInput(entry); + + assert.deepStrictEqual({ + name: ctx.templateData.name.textContent, + source: ctx.readSource().label, + ariaLabel: ctx.read().ariaLabel, + detail: { name: detail.name, label: detail.label, provenance: detail.provenance, definitionUnavailable: detail.definitionUnavailable }, + }, { + name: 'Linear (Connector)', + source: '', + ariaLabel: 'Linear (Connector), Running', + detail: { + name: 'github-copilot-connector-1', + label: 'Linear (Connector)', + provenance: { label: 'Managed by Copilot' }, + definitionUnavailable: { message: 'Copilot manages this server, so its definition can\'t be viewed or edited.' }, + }, + }); + }); + + test('built-in row menus reveal the extension or configuration file the server comes from', () => { + const ctx = createRenderer(createAgentHostServer(), false); + disposables.add(ctx.store); + const sourceActions = (entry: Extract) => ctx.menu(entry, entry.localServer) + .filter(action => action.id === 'mcpServer.showExtension' || action.id === 'mcpServer.showConfiguration'); + const extension = sourceActions({ type: 'builtin-item', id: 'github', label: 'GitHub', description: '', extensionId: new ExtensionIdentifier('GitHub.copilot-chat'), localServer: builtinLocalServer() }); + const file = sourceActions({ type: 'builtin-item', id: 'claude', label: 'Claude', description: '', localServer: builtinLocalServer(URI.file('/Users/test/Library/Application Support/Claude/claude_desktop_config.json')) }); + const none = sourceActions({ type: 'builtin-item', id: 'other', label: 'Other', description: '', localServer: builtinLocalServer() }); + [...extension, ...file].forEach(runAction); + + assert.deepStrictEqual({ + labels: [extension, file, none].map(actions => actions.map(action => action.label)), + openedExtensions: ctx.openedExtensions, + executedCommands: ctx.executedCommands, + }, { + labels: [['Show Extension'], ['Show Configuration'], []], + openedExtensions: ['GitHub.copilot-chat'], + executedCommands: [[McpCommandIds.ShowConfiguration, 'collection-1', 'server-1']], + }); + }); + function nativeServer(onShowOutput?: () => void) { const outputCalls: string[] = []; const startCalls: string[] = []; diff --git a/src/vs/workbench/contrib/chat/test/browser/attachments/chatImageReveal.test.ts b/src/vs/workbench/contrib/chat/test/browser/attachments/chatImageReveal.test.ts new file mode 100644 index 00000000000000..909f9278138be1 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/attachments/chatImageReveal.test.ts @@ -0,0 +1,272 @@ +/*--------------------------------------------------------------------------------------------- + * 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 * as dom from '../../../../../../base/browser/dom.js'; +import { mainWindow } from '../../../../../../base/browser/window.js'; +import { timeout } from '../../../../../../base/common/async.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { runWithFakedTimers } from '../../../../../../base/test/common/virtualScheduling/index.js'; +import { IAccessibilityService } from '../../../../../../platform/accessibility/common/accessibility.js'; +import { TestAccessibilityService } from '../../../../../../platform/accessibility/test/common/testAccessibilityService.js'; +import { ColorScheme } from '../../../../../../platform/theme/common/theme.js'; +import { IThemeService } from '../../../../../../platform/theme/common/themeService.js'; +import { TestColorTheme } from '../../../../../../platform/theme/test/common/testThemeService.js'; +import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; +import { getGlyphMessageBit } from '../../../browser/attachments/chatImageGlyphSurface.js'; +import { ChatImageReveal } from '../../../browser/attachments/chatImageReveal.js'; +import { ImageSamples, RevealPace } from '../../../browser/attachments/chatImageTextures.js'; + +function halves(left: string, right: string): string { + const canvas = dom.$('canvas', { width: 400, height: 240 }); + const context = canvas.getContext('2d')!; + context.fillStyle = left; + context.fillRect(0, 0, 200, 240); + context.fillStyle = right; + context.fillRect(200, 0, 200, 240); + return canvas.toDataURL('image/png'); +} + +suite('ChatImageReveal', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + let reducedMotion: boolean; + let motionChanged: Emitter; + + setup(() => { + reducedMotion = false; + motionChanged = store.add(new Emitter()); + }); + + async function render(theme = ColorScheme.DARK, width = 400, height = 240) { + const instantiationService = workbenchInstantiationService(undefined, store); + instantiationService.stub(IAccessibilityService, new class extends TestAccessibilityService { + override onDidChangeReducedMotion = motionChanged.event; + override isMotionReduced() { return reducedMotion; } + }()); + instantiationService.stub(IThemeService, { + getColorTheme: () => new TestColorTheme({}, theme), + onDidColorThemeChange: Event.None, + }); + const image = dom.$('img', { src: halves('#000', '#fff') }); + image.style.width = `${width}px`; + image.style.height = `${height}px`; + const host = dom.append(mainWindow.document.body, dom.$('div')); + host.style.width = '760px'; + const container = dom.append(host, dom.$('div', undefined, image)); + store.add(toDisposable(() => host.remove())); + const reveal = store.add(instantiationService.createInstance(ChatImageReveal, container, image, { container: host })); + await image.decode(); + return { container, image, reveal, host }; + } + + function snapshot(container: HTMLElement, image: HTMLImageElement) { + return { + pending: container.classList.contains('pending'), + revealing: container.classList.contains('revealing'), + effects: container.querySelectorAll('canvas').length, + imageOpacity: mainWindow.getComputedStyle(image).opacity, + }; + } + + function seek(container: HTMLElement, fraction: number) { + for (const animation of container.getAnimations({ subtree: true })) { + animation.pause(); + animation.currentTime = Number(animation.effect?.getTiming().duration) * fraction; + } + } + + function nextFrame() { + return new Promise(resolve => store.add(dom.scheduleAtNextAnimationFrame(mainWindow, () => resolve()))); + } + + test('the loading glyph band has a fixed width and remains painted until the image is ready', async () => { + const { container, image } = await render(); + await nextFrame(); + await nextFrame(); + const field = container.querySelector('.chat-image-loading-glyphs')!; + const pixels = field.getContext('2d')!.getImageData(0, 0, field.width, field.height).data; + assert.deepStrictEqual({ + state: snapshot(container, image), + size: [container.getBoundingClientRect().width, container.getBoundingClientRect().height], + painted: pixels.some((value, index) => index % 4 === 3 && value > 0), + }, { + state: { pending: true, revealing: false, effects: 1, imageOpacity: '0' }, + size: [320, 50], + painted: true, + }); + }); + + for (const width of [200, 400]) { + test(`the band changes width before opening into a ${width}px image and yields to the original pixels`, async () => { + const { container, image, reveal, host } = await render(ColorScheme.DARK, width); + reveal.reveal(); + const field = container.querySelector('.chat-image-loading-glyphs')!; + const at = async (fraction: number) => { + seek(container, fraction); + await nextFrame(); + const bounds = container.getBoundingClientRect(); + return { width: bounds.width, height: bounds.height, image: Number(mainWindow.getComputedStyle(image).opacity), texture: Number(field.style.opacity) }; + }; + const start = await at(0); + const widening = await at(0.05); + const middle = await at(0.5); + const end = await at(1); + reveal.dispose(); + assert.deepStrictEqual({ + start, + resizesBeforeOpening: widening.height === 50 && widening.width > Math.min(width, 320) && widening.width < Math.max(width, 320), + middle, + end, + finished: snapshot(container, image), + originClean: !host.classList.contains('chat-image-reveal-running') && !host.classList.contains('chat-image-reveal-pending'), + }, { + start: { width: 320, height: 50, image: 0, texture: 1 }, + resizesBeforeOpening: true, + middle: { width, height: 240, image: 0, texture: 1 }, + end: { width, height: 240, image: 1, texture: 0 }, + finished: { pending: false, revealing: false, effects: 0, imageOpacity: '1' }, + originClean: true, + }); + }); + } + + test('a band already matching the image width opens directly to its height', async () => { + const { container, reveal } = await render(ColorScheme.DARK, 320); + reveal.reveal(); + seek(container, 0.05); + await nextFrame(); + const bounds = container.getBoundingClientRect(); + assert.deepStrictEqual({ width: bounds.width, opening: bounds.height > 50 && bounds.height < 240 }, { width: 320, opening: true }); + }); + + test('the final frame preserves fractional CSS dimensions exactly', async () => { + const { container, image, reveal } = await render(ColorScheme.DARK, 400.25, 240.5); + reveal.reveal(); + seek(container, 1); + await nextFrame(); + const bounds = () => { + const frame = container.getBoundingClientRect(); + const imageBounds = image.getBoundingClientRect(); + return [frame.width, frame.height, imageBounds.width, imageBounds.height]; + }; + const before = bounds(); + reveal.dispose(); + assert.deepStrictEqual({ before, after: bounds() }, { before: [400.25, 240.5, 400.25, 240.5], after: [400.25, 240.5, 400.25, 240.5] }); + }); + + test('the reveal starts at twice its final speed and reaches the exact endpoint', () => { + const pace = new RevealPace(4500); + assert.deepStrictEqual({ + start: pace.virtualAt(0), + startSpeed: Math.round(pace.virtualAt(1)), + endSpeed: Math.round(4500 - pace.virtualAt(pace.duration - 1)), + end: pace.virtualAt(pace.duration), + boundedDuration: pace.duration > 2250 && pace.duration < 4500, + }, { start: 0, startSpeed: 2, endSpeed: 1, end: 4500, boundedDuration: true }); + }); + + test('time spent handling the image load does not extend the reveal deadline', async () => { + const { container, image, reveal } = await render(); + await runWithFakedTimers({ useFakeTimers: true }, async () => { + const duration = new RevealPace(4960).duration; + const loadedAt = mainWindow.performance.now(); + await timeout(100); + reveal.reveal(loadedAt); + await timeout(duration - 101); + const beforeDeadline = container.classList.contains('revealing'); + await timeout(2); + assert.deepStrictEqual({ beforeDeadline, finished: snapshot(container, image) }, { + beforeDeadline: true, + finished: { pending: false, revealing: false, effects: 0, imageOpacity: '1' }, + }); + }); + }); + + test('an expired load deadline shows the image immediately', async () => { + const { container, image, reveal } = await render(); + reveal.reveal(mainWindow.performance.now() - 10000); + assert.deepStrictEqual(snapshot(container, image), { pending: false, revealing: false, effects: 0, imageOpacity: '1' }); + }); + + for (const columns of [7, 24, 28, 31, 32, 51, 64]) { + test(`glyph digits wrap happy_coding! continuously across ${columns}-column rows`, () => { + const greeting = 'happy_coding!'.repeat(4); + let text = ''; + for (let index = 0; index < greeting.length * 8; index += 8) { + let code = 0; + for (let bit = 0; bit < 8; bit++) { + const position = index + bit; + code = code * 2 + getGlyphMessageBit(position % columns, Math.floor(position / columns), columns); + } + text += String.fromCharCode(code); + } + assert.strictEqual(text, greeting); + }); + } + + test('the standard loading band starts with the greeting in reading order', () => { + const rows = Array.from({ length: 5 }, (_, row) => { + let text = ''; + for (let column = 0; column < 32; column += 8) { + const bits = Array.from({ length: 8 }, (_, bit) => getGlyphMessageBit(column + bit, row, 32)).join(''); + text += String.fromCharCode(parseInt(bits, 2)); + } + return text; + }); + assert.deepStrictEqual(rows, ['happ', 'y_co', 'ding', '!hap', 'py_c']); + }); + + test('the palette holds the colors of the image', async () => { + const image = dom.$('img', { src: halves('#ff0000', '#0000ff') }); + await image.decode(); + const palette = ImageSamples.create(image, 400, 240, false)!.palette; + const colors = new Set(); + for (let color = 0; color < palette.length; color += 3) { + colors.add([palette[color], palette[color + 1], palette[color + 2]].map(Math.round).join(',')); + } + assert.deepStrictEqual([...colors].sort(), ['0,0,255', '255,0,0']); + }); + + for (const theme of [ColorScheme.HIGH_CONTRAST_DARK, ColorScheme.HIGH_CONTRAST_LIGHT]) { + test(`${theme} shows the loaded image without decorative effects`, async () => { + const { container, image, reveal } = await render(theme); + reveal.reveal(); + assert.deepStrictEqual(snapshot(container, image), { pending: false, revealing: false, effects: 0, imageOpacity: '1' }); + }); + } + + test('reduced motion skips the reveal without hiding the loaded image', async () => { + reducedMotion = true; + const { container, image, reveal } = await render(); + reveal.reveal(); + assert.deepStrictEqual(snapshot(container, image), { pending: false, revealing: false, effects: 0, imageOpacity: '1' }); + }); + + test('changing reduced motion during a reveal finishes immediately', async () => { + const { container, image, reveal } = await render(); + reveal.reveal(); + reducedMotion = true; + motionChanged.fire(); + assert.deepStrictEqual(snapshot(container, image), { pending: false, revealing: false, effects: 0, imageOpacity: '1' }); + }); + + test('disposing during a reveal removes its canvas and cancels completion', async () => { + const { container, image, reveal } = await render(); + await runWithFakedTimers({ useFakeTimers: true }, async () => { + reveal.reveal(); + const canvas = container.querySelector('canvas')!; + reveal.dispose(); + const disposed = snapshot(container, image); + await timeout(10000); + assert.deepStrictEqual({ disposed, later: snapshot(container, image), canvasSize: [canvas.width, canvas.height] }, { + disposed: { pending: false, revealing: false, effects: 0, imageOpacity: '1' }, + later: { pending: false, revealing: false, effects: 0, imageOpacity: '1' }, + canvasSize: [0, 0], + }); + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts index 34cb940c1846f2..c5e83d0a6181e1 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts @@ -17,6 +17,7 @@ import { IStringDictionary } from '../../../../../../base/common/collections.js' import { ILanguageModelsProviderGroup } from '../../../common/languageModelsConfiguration.js'; import { ChatAgentLocation } from '../../../common/constants.js'; import { languageModelSourcePresentationRegistry } from '../../../common/languageModelSourcePresentation.js'; +import Severity from '../../../../../../base/common/severity.js'; class MockLanguageModelsService implements ILanguageModelsService { _serviceBrand: undefined; @@ -65,6 +66,10 @@ class MockLanguageModelsService implements ILanguageModelsService { this.modelGroups.set(vendorId, groups); } + addGroupStatus(vendorId: string, message: string): void { + this.modelGroups.get(vendorId)?.push({ modelIdentifiers: [], status: { message, severity: Severity.Error } }); + } + registerLanguageModelProvider(vendor: string, provider: ILanguageModelChatProvider): IDisposable { throw new Error('Method not implemented.'); } @@ -1078,4 +1083,86 @@ suite('ChatModelsViewModel', () => { assert.deepStrictEqual(models.map(m => m.model.metadata.id), ['claude-haiku-4.5']); }); + function addHarnessModel(vendor: string, id: string, targetChatSessionType: string, modelGroup?: string): void { + languageModelsService.addModel(vendor, `${vendor}:${id}`, { + extension: new ExtensionIdentifier('vscode.chat'), + id, + name: id, + family: id, + version: '1.0', + vendor, + maxInputTokens: 128000, + maxOutputTokens: 4096, + isUserSelectable: true, + targetChatSessionType, + ...(modelGroup ? { modelGroup: { id: modelGroup } } : {}), + capabilities: { toolCalling: true, vision: false, agentMode: true }, + isDefaultForLocation: {}, + }); + } + + test('shows only the selected harness\'s Copilot list', async () => { + for (const vendor of ['copilotcli', 'agent-host-copilotcli', 'agent-host-codex', 'agent-host-claude']) { + languageModelsService.addVendor({ vendor, displayName: vendor, managementCommand: undefined, when: undefined, configuration: undefined }); + } + addHarnessModel('copilotcli', 'cli', 'copilotcli'); + addHarnessModel('agent-host-copilotcli', 'ah-cli', 'agent-host-copilotcli', 'copilotcli'); + addHarnessModel('agent-host-codex', 'codex-copilot', 'agent-host-codex', 'copilot'); + addHarnessModel('agent-host-claude', 'claude-anthropic', 'agent-host-claude', 'anthropic'); + await viewModel.refresh(); + + const listed: Record = {}; + for (const sessionType of [undefined, 'local', 'copilotcli', 'agent-host-copilotcli', 'agent-host-codex', 'agent-host-claude']) { + viewModel.setSessionType(sessionType); + listed[sessionType ?? 'all'] = viewModel.filter('').filter((r): r is ILanguageModelEntry => r.type === 'model').map(m => m.model.metadata.id).sort(); + } + + const others = ['claude-anthropic', 'gpt-3.5-turbo', 'gpt-4-vision']; + const regularCopilot = ['gpt-4', 'gpt-4o']; + assert.deepStrictEqual(listed, { + all: ['ah-cli', 'claude-anthropic', 'cli', 'codex-copilot', 'gpt-3.5-turbo', 'gpt-4', 'gpt-4-vision', 'gpt-4o'], + local: [...others, ...regularCopilot].sort(), + copilotcli: [...others, 'cli'].sort(), + 'agent-host-copilotcli': [...others, 'ah-cli'].sort(), + 'agent-host-codex': [...others, 'codex-copilot'].sort(), + 'agent-host-claude': [...others, ...regularCopilot].sort(), + }); + }); + + test('shows a Copilot provider status only for its own harness', async () => { + for (const vendor of ['agent-host-copilotcli', 'agent-host-copilot', 'ollama']) { + languageModelsService.addVendor({ vendor, displayName: vendor === 'ollama' ? 'Ollama' : 'Copilot', managementCommand: undefined, when: undefined, configuration: undefined }); + } + addHarnessModel('agent-host-copilotcli', 'ah-cli', 'agent-host-copilotcli', 'copilotcli'); + languageModelsService.addGroupStatus('agent-host-copilot', 'models failed to load'); + languageModelsService.addGroupStatus('ollama', 'fetch failed'); + await viewModel.refresh(); + + const listed: Record = {}; + for (const sessionType of ['local', 'agent-host-copilotcli', 'agent-host-copilot']) { + viewModel.setSessionType(sessionType); + listed[sessionType] = viewModel.filter('').flatMap(entry => entry.type === 'status' ? [`status: ${entry.message}`] : entry.type === 'model' ? [entry.model.metadata.id] : []); + } + + const others = ['status: fetch failed', 'gpt-3.5-turbo', 'gpt-4-vision']; + assert.deepStrictEqual(listed, { + local: ['gpt-4', 'gpt-4o', ...others], + 'agent-host-copilotcli': ['ah-cli', ...others], + 'agent-host-copilot': ['status: models failed to load', ...others], + }); + }); + + test('hiding a Copilot group hides only the models listed for the selected harness', async () => { + languageModelsService.addVendor({ vendor: 'agent-host-codex', displayName: 'Codex', managementCommand: undefined, when: undefined, configuration: undefined }); + addHarnessModel('agent-host-codex', 'codex-copilot', 'agent-host-codex', 'copilot'); + await viewModel.refresh(); + viewModel.setSessionType('agent-host-codex'); + + const copilotGroups = viewModel.filter('').filter(isLanguageModelProviderEntry).filter(entry => entry.label === 'GitHub Copilot'); + assert.strictEqual(copilotGroups.length, 1); + viewModel.toggleGroupHidden(copilotGroups[0]); + + assert.deepStrictEqual(languageModelsService.setModelsHiddenCalls, [{ modelIdentifiers: ['agent-host-codex:codex-copilot'], hidden: true }]); + }); + }); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatPetColors.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatPetColors.test.ts index 120e9248c6cda4..7be0821e9055f1 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatPetColors.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatPetColors.test.ts @@ -15,6 +15,7 @@ import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { MenuRegistry } from '../../../../../platform/actions/common/actions.js'; import { IAgentHostService } from '../../../../../platform/agentHost/common/agentService.js'; +import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { ContextKeyService } from '../../../../../platform/contextkey/browser/contextKeyService.js'; @@ -307,6 +308,7 @@ suite('Chat Pet Colors', () => { new class extends mock() { }(), createService(), new class extends mock() { override readonly isSessionsWindow = false; }(), + new class extends mock() { }(), )); const blobby = slashCommands.find(command => command.command === 'blobby'); const changeColor = MenuRegistry.getCommand(CHAT_PET_CHANGE_COLOR_COMMAND_ID); diff --git a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxAgentHostService.test.ts b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxAgentHostService.test.ts index e41db43b923d66..c00dc29287e8d0 100644 --- a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxAgentHostService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxAgentHostService.test.ts @@ -14,7 +14,10 @@ import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; import { AgentHostProtocolClient, InitialAuthenticationError, type IAgentHostProtocolClientOptions } from '../../../../../../platform/agentHost/browser/agentHostProtocolClient.js'; -import { IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; +import { toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { WebPubSubRelayTransport, type IWebPubSubRelayTransportOptions } from '../../../../../../platform/agentHost/browser/webPubSubRelayTransport.js'; +import { AgentHostTransportFailureReason, NonReconnectableTransportError, type IProtocolTransport } from '../../../../../../platform/agentHost/common/state/sessionTransport.js'; +import { IAgentConnection, IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; import { CloudSandboxEnabledSettingId, CloudSandboxRequestError, @@ -87,6 +90,10 @@ function createService(store: Pick<{ add(t: T): T const warnings: string[] = []; const removed: string[] = []; const instantiationService = store.add(new TestInstantiationService()); + let reconnectResult: CloudSandboxConnectResult | Error | Promise | undefined; + const accountChanged = store.add(new Emitter()); + let account: string | undefined = 'account-a'; + let ownEnvironment: string | undefined; const telemetry = store.add(new CloudSandboxTelemetryService(new class extends mock() { override publicLog2(eventName: string, data?: ITelemetryData): void { events.push({ eventName, data }); } }())); @@ -112,15 +119,26 @@ function createService(store: Pick<{ add(t: T): T return response; }; instantiationService.stub(ICloudSandboxApiService, new class extends mock() { + override readonly onDidChangeAccount = accountChanged.event; + override async getAccountKey(): Promise { return account; } override async connect(request: ICloudSandboxConnectionRequest, token: CancellationToken): Promise { requests.push({ method: 'connect', request, token }); return nextResult(request); } override async reconnect(request: ICloudSandboxConnectionRequest, clientId: string, token: CancellationToken): Promise { requests.push({ method: 'reconnect', request, clientId, token }); + if (reconnectResult !== undefined) { + if (reconnectResult instanceof Error) { + throw reconnectResult; + } + return reconnectResult; + } return nextResult(request); } }()); + instantiationService.stub(IAgentHostService, new class extends mock() { + override async getMissionControlEnvironmentId(): Promise { return ownEnvironment; } + }()); instantiationService.stub(IRemoteAgentHostService, new class extends mock() { override readonly onDidChangeConnections = Event.None; override get connections() { return info ? [info] : []; } @@ -155,6 +173,7 @@ function createService(store: Pick<{ add(t: T): T instantiationService.stub(IWorkbenchEnvironmentService, new class extends mock() { override readonly logsHome = URI.file('/logs'); override readonly isSessionsWindow = isSessionsWindow; + override readonly isBuilt = true; }()); instantiationService.stub(ILogService, new class extends NullLogService { override warn(message: string): void { @@ -172,6 +191,10 @@ function createService(store: Pick<{ add(t: T): T requestCalls: () => calls, requests, events, + setReconnectResult: (value: CloudSandboxConnectResult | Error | Promise) => { reconnectResult = value; }, + entries: () => factory?.entries.get() ?? [], + changeAccount(value: string | undefined): void { account = value; accountChanged.fire(value); }, + setOwnEnvironment(value: string): void { ownEnvironment = value; }, warnings, removed, started: started.p, @@ -198,6 +221,107 @@ suite('CloudSandboxAgentHostService', () => { teardown(() => sinon.restore()); + test('user-local connect refuses the owning native host instead of relaying to itself', async () => { + const fixture = createService(store, [{ kind: 'token', token: clientToken('copilot-sealed.v1.key.box') }]); + fixture.setOwnEnvironment('native-self'); + await assert.rejects(fixture.service.connect({ environmentId: 'native-self', name: 'Self', environmentKind: 'user-local' }, CancellationToken.None), /local IPC/); + assert.deepStrictEqual({ requests: fixture.requestCalls(), entries: fixture.entries().length }, { requests: 0, entries: 0 }); + }); + + test('a user-local ticket from a replaced account is cancelled before staging', async () => { + const minted = new DeferredPromise(); + const started = new DeferredPromise(); + const fixture = createService(store, [async () => { started.complete(); return minted.p; }]); + const operation = fixture.service.connect({ environmentId: 'native', name: 'Native', environmentKind: 'user-local' }, CancellationToken.None); + await started.p; + fixture.changeAccount('account-b'); + minted.complete({ kind: 'token', token: clientToken('copilot-sealed.v1.key.box') }); + await assert.rejects(operation, isCancellationError); + assert.strictEqual(fixture.entries().length, 0); + }); + + test('signout withdraws staged user-local credentials and its remote connection', async () => { + const fixture = createService(store, [{ kind: 'token', token: clientToken('copilot-sealed.v1.key.box') }]); + fixture.service.connectThroughFactory = true; + const operation = fixture.service.connect({ environmentId: 'native', name: 'Native', environmentKind: 'user-local' }, CancellationToken.None); + await fixture.started; + fixture.settle(); + await operation; + assert.strictEqual(fixture.entries().length, 1); + fixture.changeAccount(undefined); + await timeout(0); + assert.strictEqual(fixture.entries().length, 0); + }); + + test('factory recovery reuses unchanged sealed credentials while installing the fresh WPS ticket', () => runWithFakedTimers({}, async () => { + const key = { key_id: 'key', use: 'auth-token', algorithm: 'x25519-sealedbox', public_key: 'public' }; + const initial = { ...clientToken('copilot-sealed.v1.key.box'), host_encryption_key: key }; + const fixture = createService(store, [{ kind: 'token', token: initial }]); + fixture.service.connectThroughFactory = true; + const connecting = fixture.service.connect({ environmentId: 'sandbox', name: 'Sandbox', environmentKind: 'user-local' }, CancellationToken.None); + await fixture.started; + fixture.settle(); + await connecting; + fixture.instantiationService.stubInstance(AgentHostProtocolClient, new class extends mock() { + override readonly onDidChangeConnectionState = Event.None; + override readonly onDidConnectionDiagnostic = Event.None; + }()); + const spy = sinon.spy(fixture.instantiationService, 'createInstance'); + store.add(toDisposable(() => spy.restore())); + const factory = fixture.getFactory(); + const entry = fixture.entries()[0]; + const created = await factory.createConnection(entry, { userInitiated: true }); + assert.ok(created.transportDisposable); + store.add(created.transportDisposable); + const call = spy.getCalls().find(call => call.args[0] === AgentHostProtocolClient); + assert.ok(call); + const options = call.args[3] as IAgentHostProtocolClientOptions; + assert.ok(options.prepareReconnect); + assert.ok(options.resolveInitialAuthentication); + fixture.setReconnectResult({ kind: 'token', token: { ...clientToken(undefined), access_token: 'fresh-ticket' } }); + await options.prepareReconnect(); + const makeTransport = call.args[2] as () => IProtocolTransport; + const transport = store.add(makeTransport()); + const transportCall = spy.getCalls().find(call => call.args[0] === WebPubSubRelayTransport); + assert.ok(transportCall); + const transportOptions = transportCall.args[1] as IWebPubSubRelayTransportOptions; + assert.strictEqual(new URL(transportOptions.url).searchParams.get('access_token'), 'fresh-ticket'); + assert.deepStrictEqual(await options.resolveInitialAuthentication(), { resource: 'https://api.github.com', token: initial.encrypted_github_token }); + await timeout(30_000); + fixture.setReconnectResult({ kind: 'token', token: { ...clientToken(undefined), host_encryption_key: { ...key, key_id: 'different' } } }); + await assert.rejects(options.prepareReconnect(), /could not be refreshed/); + fixture.setReconnectResult(new CloudSandboxRequestError(404, 'Environment not found')); + await timeout(30_000); + await assert.rejects(options.prepareReconnect(), error => error instanceof NonReconnectableTransportError && error.reason === AgentHostTransportFailureReason.HostNotRunning); + assert.strictEqual(fixture.entries().length, 0); + const clientCreations = spy.getCalls().filter(call => call.args[0] === AgentHostProtocolClient).length; + await assert.rejects(factory.createConnection(entry, { userInitiated: false }), /No cloud sandbox connection is staged/); + assert.strictEqual(spy.getCalls().filter(call => call.args[0] === AgentHostProtocolClient).length, clientCreations); + const stagingFactory = factory as IRemoteAgentHostConnectionFactory & { + stageConfiguration(options: ICloudSandboxConnectOptions, token: ICloudSandboxClientToken): void; + }; + const addressOptions: ICloudSandboxConnectOptions = { environmentId: 'sandbox', name: 'Sandbox', environmentKind: 'user-local' }; + stagingFactory.stageConfiguration(addressOptions, initial); + const replacement = await factory.createConnection(entry, { userInitiated: true }); + assert.ok(replacement.transportDisposable); + store.add(replacement.transportDisposable); + const replacementCall = spy.getCalls().filter(call => call.args[0] === AgentHostProtocolClient).at(-1); + assert.ok(replacementCall); + const replacementOptions = replacementCall.args[3] as IAgentHostProtocolClientOptions; + assert.ok(replacementOptions.prepareReconnect); + const late = new DeferredPromise(); + fixture.setReconnectResult(late.p); + const pending = replacementOptions.prepareReconnect(); + stagingFactory.stageConfiguration(addressOptions, { ...initial, access_token: 'newer-staging' }); + late.error(new CloudSandboxRequestError(404, 'Old environment ticket refused')); + await assert.rejects(pending, isCancellationError); + assert.strictEqual(fixture.entries().length, 1); + created.transportDisposable.dispose(); + replacement.transportDisposable.dispose(); + transport.dispose(); + fixture.service.dispose(); + })); + for (const isSessionsWindow of [false, true]) { test(`wires protocol diagnostics and real window identity for ${isSessionsWindow ? 'Agents' : 'Editor'}`, () => runWithFakedTimers({}, async () => { const fixture = createService(store, [{ kind: 'token', token: clientToken('copilot-sealed.v1.key.payload') }], undefined, isSessionsWindow); @@ -340,8 +464,9 @@ suite('CloudSandboxAgentHostService', () => { }); const createInstance = sinon.spy(fixture.instantiationService, 'createInstance'); const factory = fixture.getFactory(); + const entry = factory.entries.get()[0]; async function createConnection(userInitiated: boolean) { - const created = await factory.createConnection(factory.entries.get()[0], { userInitiated }); + const created = await factory.createConnection(entry, { userInitiated }); assert.ok(created.transportDisposable); store.add(created.transportDisposable); store.add(created.connection); @@ -534,10 +659,9 @@ suite('CloudSandboxAgentHostService', () => { test('preserves a permanent refresh stop across automatic client replacements', () => runWithFakedTimers({}, async () => { const fixture = await createRecoveryFixture(async () => { throw new CloudSandboxRequestError(404, 'environment gone'); }); - await assert.rejects(fixture.prepareReconnect(), /usable future expiry/); + await assert.rejects(fixture.prepareReconnect(), error => error instanceof NonReconnectableTransportError && error.reason === AgentHostTransportFailureReason.HostNotRunning); for (let redial = 0; redial < 2; redial++) { - await fixture.redial(); - await assert.rejects(fixture.prepareReconnect(), /stopped/); + await assert.rejects(fixture.redial(), /No cloud sandbox connection is staged/); await timeout(60_000); } await fixture.finish(); diff --git a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxApiService.test.ts b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxApiService.test.ts index cfe7c8574cefcf..6f1c8e539b6d7d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxApiService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxApiService.test.ts @@ -429,6 +429,91 @@ suite('CloudSandboxApiService connection credentials', () => { } }); +suite('Mission Control environment discovery', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('caches credential-free metadata and invalidates it when the account changes', async () => { + const { service, requestedUrls, changeAuthentication } = createService(store, { + tasks: [], repositories: new Map(), + onRequest: url => url.pathname.endsWith('/agents/environments') ? jsonResponse([{ + id: 'host', kind: 'user-local', name: 'Native host', status: 'online', webpubsub: { access_token: 'must-not-be-cached' }, + }]) : undefined, + }); + const first = await service.listEnvironments(CancellationToken.None); + assert.strictEqual(await service.listEnvironments(CancellationToken.None), first); + assert.strictEqual(service.getCachedEnvironments(), first); + assert.deepStrictEqual(first, [{ id: 'host', kind: 'user-local', name: 'Native host', status: 'online' }]); + assert.strictEqual(requestedUrls.filter(url => url.endsWith('/agents/environments')).length, 1); + changeAuthentication(); + assert.strictEqual(service.getCachedEnvironments(), undefined); + await service.listEnvironments(CancellationToken.None); + assert.strictEqual(requestedUrls.filter(url => url.endsWith('/agents/environments')).length, 2); + }); + + test('does not return expired cached inventory or fetch merely to read the cache', async () => { + await runWithFakedTimers({}, async () => { + const { service, requestedUrls } = createService(store, { + tasks: [], repositories: new Map(), + onRequest: url => url.pathname.endsWith('/agents/environments') ? jsonResponse([]) : undefined, + }); + const empty = service.getCachedEnvironments(); + await service.listEnvironments(CancellationToken.None); + const cached = service.getCachedEnvironments(); + await timeout(60_000); + assert.deepStrictEqual({ + empty, cached, expired: service.getCachedEnvironments(), + requests: requestedUrls.filter(url => url.endsWith('/agents/environments')).length, + }, { empty: undefined, cached: [], expired: undefined, requests: 1 }); + }); + }); + + test('skips unusable metadata without hiding other valid environments or rejecting future statuses', async () => { + const { service } = createService(store, { + tasks: [], repositories: new Map(), + onRequest: url => url.pathname.endsWith('/agents/environments') ? jsonResponse([ + { id: 'invalid', name: 'Invalid host', kind: 'user-local' }, + { id: 'host', name: 'Native host', kind: 'user-local', status: 'online' }, + { id: 'managed', name: 'Managed host', kind: 'managed-sandbox', status: 'paused' }, + ]) : undefined, + }); + assert.deepStrictEqual(await service.listEnvironments(CancellationToken.None), [ + { id: 'host', name: 'Native host', kind: 'user-local', status: 'online' }, + { id: 'managed', name: 'Managed host', kind: 'managed-sandbox', status: 'paused' }, + ]); + }); + + test('explicit inventory refresh observes a host started since the cached offline result', async () => { + let status = 'offline'; + const { service } = createService(store, { + tasks: [], repositories: new Map(), + onRequest: url => url.pathname.endsWith('/agents/environments') ? jsonResponse([{ id: 'host', name: 'Native host', kind: 'user-local', status }]) : undefined, + }); + assert.strictEqual((await service.listEnvironments(CancellationToken.None))[0].status, 'offline'); + status = 'online'; + assert.strictEqual((await service.listEnvironments(CancellationToken.None, { refresh: true }))[0].status, 'online'); + }); + + test('does not publish a late inventory from a previous account', async () => { + const started = new DeferredPromise(); + const response = new DeferredPromise(); + const { service, changeAuthentication } = createService(store, { + tasks: [], repositories: new Map(), + onRequest: url => { + if (url.pathname.endsWith('/agents/environments')) { + started.complete(); + return response.p; + } + return undefined; + }, + }); + const inventory = service.listEnvironments(CancellationToken.None); + await started.p; + changeAuthentication(); + response.complete(jsonResponse([{ id: 'host', kind: 'user-local', name: 'Old account', status: 'online' }])); + await assert.rejects(inventory, CancellationError); + }); +}); + suite('CloudSandboxApiService repository resolution', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); diff --git a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxConnectionCustomization.test.ts b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxConnectionCustomization.test.ts index 9b6c2689f1466a..f8d8111a1e3628 100644 --- a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxConnectionCustomization.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxConnectionCustomization.test.ts @@ -55,6 +55,23 @@ suite('CloudSandboxConnectionCustomization authentication', () => { }); }); + test('user-local MCP renewal stays on the trusted workbench sealing path', async () => { + const requests: string[] = []; + const service = new class extends mock() { }(); + const customization = createCloudSandboxConnectionCustomization(cloudSandboxAddress('env-1'), service, true, async request => { + requests.push(request.resource); + return { ...request, token: 'copilot-sealed.v1.mcp.fresh' }; + }); + assert.ok(customization?.authenticate); + const request = { resource: 'https://mcp.example.test', scopes: ['mcp'], token: 'test-mcp-token' }; + const result = await customization.authenticate(request, AuthRequiredReason.Expired); + assert.deepStrictEqual({ result, requests, renewal: customization.renewAuthentication }, { + result: { ...request, token: 'copilot-sealed.v1.mcp.fresh' }, + requests: ['https://mcp.example.test'], + renewal: undefined, + }); + }); + for (const token of ['plaintext', 'copilot-sealed.v1.key.challenged']) { test(`renews an expired challenge instead of forwarding ${token}`, async () => { const fixture = createFixture(); diff --git a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxCredentialRefresh.test.ts b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxCredentialRefresh.test.ts index 15e7449e827f0a..d4dc90cc08978a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxCredentialRefresh.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxCredentialRefresh.test.ts @@ -247,6 +247,32 @@ suite('CloudSandboxCredentialRefresher', () => { ); })); + test('a late periodic refresh cannot replace a newer host-recovery credential', () => runWithFakedTimers({ useFakeTimers: true, startTime: START_TIME }, async () => { + const response = new DeferredPromise(); + const started = new DeferredPromise(); + const creds: ICloudSandboxCreds = { token: tokenExpiringIn(2, START_TIME) }; + const replacement = tokenExpiringIn(40, START_TIME, { access_token: 'recovery-token', host_encryption_key: REPLACEMENT_HOST_KEY }); + const telemetry = new RecordingTelemetry(); + const api = new class extends mock() { + override reconnect(): Promise { + started.complete(); + return response.p; + } + }(); + const owner = new DisposableStore(); + try { + owner.add(new CloudSandboxCredentialRefresher('cloudsandbox:env_1', { environmentId: 'env_1' }, 'client-1', creds, new CloudSandboxCredentialRefreshState(), api, telemetry, new NullLogService())); + await started.p; + creds.token = replacement; + response.complete({ kind: 'token', token: tokenExpiringIn(40, START_TIME, { access_token: 'stale-token' }) }); + await timeout(1); + assert.strictEqual(creds.token, replacement); + assert.deepStrictEqual(telemetry.stops, []); + } finally { + owner.dispose(); + } + })); + test('a refreshed token without a sealed GitHub token keeps the previous one', () => runWithFakedTimers({ useFakeTimers: true, startTime: START_TIME }, async () => { const result = await runRefresher( () => ({ kind: 'token', token: tokenExpiringIn(40, Date.now(), { access_token: 'fresh' }) }), diff --git a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxLegacySessionPreparation.test.ts b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxLegacySessionPreparation.test.ts index a42686bd718ad5..84fe97ed2ef874 100644 --- a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxLegacySessionPreparation.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/cloudSandboxLegacySessionPreparation.test.ts @@ -68,6 +68,17 @@ suite('CloudSandboxLegacySessionPreparation', () => { }, { ordinary: undefined, sandbox: 'function' }); }); + test('user-local environments keep native provider schemes without sandbox checkout preparation', () => { + const service = new class extends mock() { }(); + const customization = createCloudSandboxConnectionCustomization(cloudSandboxAddress('native-host'), service, true); + assert.deepStrictEqual({ + preparation: customization?.createSessionPreparation, + nativeCopilot: customization?.backendSessionScheme?.('copilotcli'), + daemonCopilot: customization?.backendSessionScheme?.('copilot'), + claude: customization?.backendSessionScheme?.('claude'), + }, { preparation: undefined, nativeCopilot: 'ahp-session', daemonCopilot: 'ahp-session', claude: undefined }); + }); + test('binds preparation to its protocol connection and releases it when that connection closes', async () => { const root = store.add(new RootStateSubscription('test-client', () => { })); root.handleSnapshot(rootState([]), 0); diff --git a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/editorCloudSandboxContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/editorCloudSandboxContribution.test.ts index 1407e13e9ba42c..154e019aa83b7b 100644 --- a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/editorCloudSandboxContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/editorCloudSandboxContribution.test.ts @@ -8,14 +8,15 @@ import { DeferredPromise, timeout } from '../../../../../../base/common/async.js import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { isCancellationError } from '../../../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; -import { DisposableStore, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { constObservable, observableValue } from '../../../../../../base/common/observable.js'; import { extUriBiasedIgnorePathCase } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { mock, upcastPartial } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { runWithFakedTimers } from '../../../../../../base/test/common/virtualScheduling/index.js'; -import { AgentSession, IAgentConnection, IAgentSessionMetadata } from '../../../../../../platform/agentHost/common/agentService.js'; +import { AgentSession, IAgentConnection, IAgentHostService, IAgentSessionMetadata } from '../../../../../../platform/agentHost/common/agentService.js'; +import { AgentHostConnectionsService } from '../../../../../../platform/agentHost/browser/agentHostConnectionsService.js'; import { IAgentHostConnectionsService, IAgentHostSessionResolutionPolicy } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { agentHostAuthority, createAgentHostResourceUriMapper, toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { remoteAgentHostSessionTypeId } from '../../../../../../platform/agentHost/common/agentHostSessionType.js'; @@ -35,6 +36,7 @@ import { IDialogService } from '../../../../../../platform/dialogs/common/dialog import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; import { INotificationService, NotificationMessage } from '../../../../../../platform/notification/common/notification.js'; +import { IPathService } from '../../../../../../platform/path/common/pathService.js'; import { InMemoryStorageService, IStorageService } from '../../../../../../platform/storage/common/storage.js'; import { IWorkspaceContextService, IWorkspaceFoldersChangeEvent, toWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; import { IWorkspaceTrustManagementService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; @@ -234,6 +236,7 @@ function createHarness(store: Pick, options?: { } }(); instantiationService.stub(IChatSessionsService, chatSessionsService); + instantiationService.stub(IAgentHostService, new class extends mock() { }()); instantiationService.stub(IConfigurationService, configuration); instantiationService.stub(ILogService, new class extends NullLogService { override warn(message: string): void { calls.repositoryErrors.push(message); } @@ -306,6 +309,7 @@ function createHarness(store: Pick, options?: { })] : []; } override getConnection(candidate: string) { return state.connected && candidate === address ? connection : undefined; } + override getConnectionByAuthority(candidate: string) { return state.connected && candidate === authority ? connection : undefined; } override async removeRemoteAgentHost(candidate: string) { calls.removed.push(candidate); state.connected = false; @@ -313,8 +317,15 @@ function createHarness(store: Pick, options?: { }()); instantiationService.stub(IRemoteAgentHostConnectionCustomizationService, new RemoteAgentHostConnectionCustomizationService()); instantiationService.stub(IRemoteAgentHostAuthenticationService, authenticationService); + instantiationService.stub(IAgentHostService, new class extends mock() { + override readonly onAgentHostStart = Event.None; + override readonly onAgentHostExit = Event.None; + override readonly onDidNotification = Event.None; + override readonly resourceUris = createAgentHostResourceUriMapper('local'); + }()); + instantiationService.stub(IPathService, { registerPathProvider: () => Disposable.None }); const policies: string[] = []; - instantiationService.stub(IAgentHostConnectionsService, new class extends mock() { + instantiationService.stub(IAgentHostConnectionsService, store.add(instantiationService.createInstance(class extends AgentHostConnectionsService { override registerSessionResolutionPolicy(connectionAuthority: string, policy: IAgentHostSessionResolutionPolicy) { assert.ok(policy.connectionAddress); assert.deepStrictEqual({ @@ -326,10 +337,14 @@ function createHarness(store: Pick, options?: { sessionSchemeAlias: { ui: 'copilot', backend: 'ahp-session' }, defaultChangesetKind: ChangesetKind.Session, }); + const registration = super.registerSessionResolutionPolicy(connectionAuthority, policy); policies.push(connectionAuthority); - return toDisposable(() => policies.splice(policies.indexOf(connectionAuthority), 1)); + return toDisposable(() => { + registration.dispose(); + policies.splice(policies.indexOf(connectionAuthority), 1); + }); } - }()); + }))); const resolvers = new Map URI | undefined; isNew: (resource: URI) => boolean }>(); instantiationService.stub(IAgentHostSessionWorkingDirectoryResolver, new class extends mock() { override registerResolver(type: string, resolve: (resource: URI) => URI | undefined, isNew: (resource: URI) => boolean = () => false) { diff --git a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/missionControlCredentialSealing.test.ts b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/missionControlCredentialSealing.test.ts new file mode 100644 index 00000000000000..1d1e3d3ecae8ec --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/missionControlCredentialSealing.test.ts @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * 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 type { IMissionControlCredentialSealingRequest } from '../../../../../../platform/agentHost/common/agentService.js'; +import { createRootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { sealMissionControlMcpCredential } from '../../../browser/remoteAgentHost/missionControlCredentialSealing.js'; + +suite('Mission Control MCP recipient trust', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + const key = { key_id: 'key', use: 'mcp-auth-token', algorithm: 'x25519-sealedbox', public_key: 'public' }; + const root = () => ({ ...createRootState(), _meta: { 'copilot.encryptionKeys': [{ keyId: key.key_id, use: key.use, algorithm: key.algorithm, publicKey: key.public_key }] } }); + const request = { resource: 'https://mcp.example.test', token: 'sensitive-test-token', scopes: ['read'] }; + + test('requires the same HTTPS descriptor and live host key before disclosing to local sealing', async () => { + const calls: IMissionControlCredentialSealingRequest[] = []; + const seal = async (value: IMissionControlCredentialSealingRequest) => { calls.push(value); return 'copilot-sealed.v1.test.box'; }; + await assert.rejects(sealMissionControlMcpCredential(request, undefined, root(), undefined, seal), /authenticated MCP/); + await assert.rejects(sealMissionControlMcpCredential(request, [{ ...key, public_key: 'substituted' }], root(), undefined, seal), /does not match/); + assert.strictEqual(calls.length, 0); + const result = await sealMissionControlMcpCredential(request, [key], root(), { 'copilot.authChallenge': { challenge: 'a'.repeat(32), required: false } }, seal); + assert.deepStrictEqual(result, { ...request, token: 'copilot-sealed.v1.test.box' }); + assert.deepStrictEqual(calls, [{ resource: request.resource, token: request.token, key, challenge: 'a'.repeat(32) }]); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/missionControlEnvironmentActions.test.ts b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/missionControlEnvironmentActions.test.ts new file mode 100644 index 00000000000000..59e379774c36cf --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/remoteAgentHost/missionControlEnvironmentActions.test.ts @@ -0,0 +1,268 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { DeferredPromise } from '../../../../../../base/common/async.js'; +import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { CancellationError } from '../../../../../../base/common/errors.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { mock } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; +import { cloudSandboxAddress, ICloudSandboxAgentHostService, ICloudSandboxApiService, ICloudSandboxConnectOptions, ICloudSandboxEnvironment, IMissionControlEnvironment } from '../../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; +import { CommandsRegistry } from '../../../../../../platform/commands/common/commands.js'; +import { IEnvironmentService } from '../../../../../../platform/environment/common/environment.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; +import { IQuickInputHideEvent, IQuickInputService, IQuickPick, IQuickPickDidAcceptEvent, IQuickPickItem, QuickInputHideReason } from '../../../../../../platform/quickinput/common/quickInput.js'; +import { IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; +import { ConnectMissionControlEnvironmentCommand } from '../../../browser/remoteAgentHost/missionControlEnvironmentActions.js'; + +class TestEnvironmentQuickPick extends mock>() { + private readonly store = new DisposableStore(); + private readonly hideEmitter = this.store.add(new Emitter()); + private readonly acceptEmitter = this.store.add(new Emitter()); + private readonly itemsEmitter = this.store.add(new Emitter()); + private currentItems: readonly IQuickPickItem[] = []; + private visible = false; + readonly shown = new DeferredPromise(); + readonly onDidChangeItems = this.itemsEmitter.event; + override readonly onDidHide = this.hideEmitter.event; + override readonly onDidAccept = this.acceptEmitter.event; + override selectedItems: readonly IQuickPickItem[] = []; + override activeItems: readonly IQuickPickItem[] = []; + override value = ''; + override busy = false; + disposed = false; + + override get items(): readonly IQuickPickItem[] { return this.currentItems; } + override set items(items: readonly IQuickPickItem[]) { + this.currentItems = items; + this.itemsEmitter.fire(); + } + override show(): void { + this.visible = true; + void this.shown.complete(); + } + override hide(): void { + if (this.visible) { + this.visible = false; + this.hideEmitter.fire({ reason: QuickInputHideReason.Other }); + } + } + override accept(inBackground = false): void { + this.acceptEmitter.fire({ inBackground }); + } + override dispose(): void { + this.hide(); + this.disposed = true; + this.store.dispose(); + } +} + +function environment(id: string, status = 'online', kind = 'user-local'): IMissionControlEnvironment { + return { id, name: `Host ${id}`, status, kind }; +} + +suite('Mission Control environment picker', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function fixture(cached?: readonly IMissionControlEnvironment[], status: ICloudSandboxEnvironment['status'] = 'online', hidden = false) { + const quickPick = store.add(new TestEnvironmentQuickPick()); + const instantiationService = store.add(new TestInstantiationService()); + const inventory = new DeferredPromise(); + const errorShown = new DeferredPromise(); + const calls = { + refresh: [] as ({ readonly refresh?: boolean } | undefined)[], + tokens: [] as CancellationToken[], + lookups: [] as string[], + connections: [] as ICloudSandboxConnectOptions[], + errors: [] as string[], + warnings: [] as string[], + }; + instantiationService.stub(IEnvironmentService, new class extends mock() { + override readonly isBuilt = true; + }()); + instantiationService.stub(IChatEntitlementService, new class extends mock() { + override readonly sentiment = { hidden }; + }()); + instantiationService.stub(IAgentHostService, new class extends mock() { + override async getMissionControlEnvironmentId() { return 'own'; } + }()); + instantiationService.stub(ICloudSandboxApiService, new class extends mock() { + override getCachedEnvironments() { return cached; } + override listEnvironments(token: CancellationToken, options?: { readonly refresh?: boolean }) { + calls.refresh.push(options); + calls.tokens.push(token); + return inventory.p; + } + override async getEnvironment(id: string) { + calls.lookups.push(id); + return { id, status }; + } + }()); + instantiationService.stub(ICloudSandboxAgentHostService, new class extends mock() { + override async connect(options: ICloudSandboxConnectOptions) { + calls.connections.push(options); + return cloudSandboxAddress(options.environmentId); + } + }()); + instantiationService.stub(INotificationService, new class extends mock() { + override error(message: Parameters[0]): void { + calls.errors.push(String(message)); + void errorShown.complete(); + } + override warn(message: Parameters[0]): void { + calls.warnings.push(String(message)); + } + }()); + instantiationService.stub(IQuickInputService, {}, 'createQuickPick', () => quickPick); + const run = () => instantiationService.invokeFunction(accessor => { + const command = CommandsRegistry.getCommand(ConnectMissionControlEnvironmentCommand); + assert.ok(command); + return command.handler(accessor); + }); + return { quickPick, inventory, errorShown, calls, run }; + } + + test('shows cached hosts immediately, refreshes inventory, and preserves the search and active host', async () => { + const { quickPick, inventory, calls, run } = fixture([ + environment('a', 'offline'), environment('own'), environment('managed', 'online', 'managed-sandbox'), + ]); + const done = run(); + await quickPick.shown.p; + const initial = { labels: quickPick.items.map(item => item.label), busy: quickPick.busy }; + quickPick.value = 'a'; + quickPick.activeItems = [quickPick.items[0]]; + const refreshed = Event.toPromise(quickPick.onDidChangeItems); + await inventory.complete([ + { ...environment('a'), name: 'Renamed A' }, environment('b'), environment('own'), environment('managed', 'online', 'managed-sandbox'), + ]); + await refreshed; + const updated = { + labels: quickPick.items.map(item => item.label), + query: quickPick.value, active: quickPick.activeItems.map(item => item.label), busy: quickPick.busy, + }; + quickPick.selectedItems = [quickPick.items[0]]; + quickPick.accept(); + await done; + assert.deepStrictEqual({ + initial, updated, refresh: calls.refresh, lookups: calls.lookups, connections: calls.connections, + errors: calls.errors, disposed: quickPick.disposed, cancelled: calls.tokens[0].isCancellationRequested, + }, { + initial: { labels: ['Host a'], busy: false }, + updated: { labels: ['Host b', 'Renamed A'], query: 'a', active: ['Renamed A'], busy: false }, + refresh: [{ refresh: true }], lookups: ['b'], + connections: [{ environmentId: 'b', name: 'Host b', environmentKind: 'user-local' }], + errors: [], disposed: true, cancelled: true, + }); + }); + + test('does not expose discovery when AI features are disabled', async () => { + const { calls, quickPick, run } = fixture(undefined, 'online', true); + await assert.rejects(async () => run(), /AI features are disabled/); + assert.deepStrictEqual({ requests: calls.refresh, shown: quickPick.shown.isSettled }, { requests: [], shown: false }); + }); + + test('a cold picker shows progress until one fresh inventory supplies hosts', async () => { + const { quickPick, inventory, calls, run } = fixture(); + const done = run(); + await quickPick.shown.p; + const initial = { items: quickPick.items.length, busy: quickPick.busy }; + const refreshed = Event.toPromise(quickPick.onDidChangeItems); + await inventory.complete([environment('b'), environment('own')]); + await refreshed; + const updated = { labels: quickPick.items.map(item => item.label), busy: quickPick.busy }; + quickPick.hide(); + await done; + assert.deepStrictEqual({ + initial, updated, refresh: calls.refresh, connections: calls.connections, errors: calls.errors, + }, { + initial: { items: 0, busy: true }, updated: { labels: ['Host b'], busy: false }, + refresh: [{ refresh: true }], connections: [], errors: [], + }); + }); + + test('a cached host can be accepted before the refresh finishes and is revalidated live', async () => { + const { quickPick, inventory, calls, run } = fixture([environment('a', 'offline')]); + const done = run(); + await quickPick.shown.p; + quickPick.selectedItems = [quickPick.items[0]]; + quickPick.accept(); + await done; + await inventory.complete([environment('b')]); + assert.deepStrictEqual({ + labels: quickPick.items.map(item => item.label), lookups: calls.lookups, connections: calls.connections, + cancelled: calls.tokens[0].isCancellationRequested, errors: calls.errors, + }, { + labels: ['Host a'], lookups: ['a'], + connections: [{ environmentId: 'a', name: 'Host a', environmentKind: 'user-local' }], + cancelled: true, errors: [], + }); + }); + + test('closing the picker cancels refresh and ignores a late inventory without connecting', async () => { + const { quickPick, inventory, calls, run } = fixture(); + const done = run(); + await quickPick.shown.p; + const initialBusy = quickPick.busy; + quickPick.hide(); + await done; + await inventory.complete([environment('b')]); + assert.deepStrictEqual({ + initialBusy, labels: quickPick.items.map(item => item.label), connections: calls.connections, + cancelled: calls.tokens[0].isCancellationRequested, disposed: quickPick.disposed, errors: calls.errors, + }, { initialBusy: true, labels: [], connections: [], cancelled: true, disposed: true, errors: [] }); + }); + + for (const cached of [undefined, [], [environment('a')]]) { + const hasCachedHosts = !!cached?.length; + test(`reports refresh errors ${hasCachedHosts ? 'without removing useful cached hosts' : `and closes an empty picker (cached: ${!!cached})`}`, async () => { + const { quickPick, inventory, errorShown, calls, run } = fixture(cached); + const done = run(); + await quickPick.shown.p; + await inventory.error(new Error('Inventory failed')); + await errorShown.p; + const disposedOnError = quickPick.disposed; + quickPick.hide(); + await done; + assert.deepStrictEqual({ + labels: quickPick.items.map(item => item.label), errors: calls.errors, disposedOnError, + connections: calls.connections, disposed: quickPick.disposed, + }, { + labels: hasCachedHosts ? ['Host a'] : [], errors: ['Error: Inventory failed'], disposedOnError: !hasCachedHosts, + connections: [], disposed: true, + }); + }); + } + + test('account-change cancellation closes the picker without surfacing an error', async () => { + const { quickPick, inventory, calls, run } = fixture([environment('a')]); + const done = run(); + await quickPick.shown.p; + await inventory.error(new CancellationError()); + await done; + assert.deepStrictEqual({ + errors: calls.errors, connections: calls.connections, disposed: quickPick.disposed, + }, { errors: [], connections: [], disposed: true }); + }); + + test('an environment that went offline is not connected or replaced', async () => { + const { quickPick, inventory, calls, run } = fixture([environment('a')], 'offline'); + const done = run(); + await quickPick.shown.p; + quickPick.selectedItems = [quickPick.items[0]]; + quickPick.accept(); + await done; + await inventory.complete([]); + assert.deepStrictEqual({ + lookups: calls.lookups, connections: calls.connections, errors: calls.errors, warnings: calls.warnings, + }, { + lookups: ['a'], connections: [], errors: [], + warnings: ['Host a is not online. Start its owning application before connecting.'], + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts index 9b4e3b30b3fe40..efe3bd21310619 100644 --- a/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts @@ -12,6 +12,7 @@ import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { autorun, derived, ISettableObservable, observableFromEvent, observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { mock } from '../../../../../../base/test/common/mock.js'; +import { IAgentHostConnectionsService } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IAccessibilityService } from '../../../../../../platform/accessibility/common/accessibility.js'; import { TestAccessibilityService } from '../../../../../../platform/accessibility/test/common/testAccessibilityService.js'; @@ -891,6 +892,11 @@ suite('VoiceSessionController', () => { notificationService, promptsService, chatEntitlementService, + new class extends mock() { + override resolveSessionResourceIdentity(resource: URI) { + return { connectionAuthority: 'local', backendSession: resource.with({ scheme: resource.scheme.substring('agent-host-'.length) }) }; + } + }(), )); } @@ -6369,6 +6375,7 @@ suite('VoiceSessionController live transcription', () => { const chatEntitlementService = new TestChatEntitlementService(); chatEntitlementService.entitlement = ChatEntitlement.Pro; instantiationService.stub(IChatEntitlementService, chatEntitlementService); + instantiationService.stub(IAgentHostConnectionsService, new class extends mock() { }()); const controller = store.add(instantiationService.createInstance(VoiceSessionController)); controller['_isConnected'].set(true, undefined); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatResourceGroupWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatResourceGroupWidget.test.ts new file mode 100644 index 00000000000000..e9129bca0b5d53 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatResourceGroupWidget.test.ts @@ -0,0 +1,452 @@ +/*--------------------------------------------------------------------------------------------- + * 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 * as dom from '../../../../../../../base/browser/dom.js'; +import { mainWindow } from '../../../../../../../base/browser/window.js'; +import { DeferredPromise, retry, timeout } from '../../../../../../../base/common/async.js'; +import { decodeBase64, VSBuffer } from '../../../../../../../base/common/buffer.js'; +import { Disposable, toDisposable } from '../../../../../../../base/common/lifecycle.js'; +import { ResourceMap } from '../../../../../../../base/common/map.js'; +import { Schemas } from '../../../../../../../base/common/network.js'; +import { dirname } from '../../../../../../../base/common/resources.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 { IAccessibilityService } from '../../../../../../../platform/accessibility/common/accessibility.js'; +import { TestAccessibilityService } from '../../../../../../../platform/accessibility/test/common/testAccessibilityService.js'; +import { CommandsRegistry } from '../../../../../../../platform/commands/common/commands.js'; +import { IFileDialogService } from '../../../../../../../platform/dialogs/common/dialogs.js'; +import { FileService } from '../../../../../../../platform/files/common/fileService.js'; +import { FileSystemProviderCapabilities, IFileService } from '../../../../../../../platform/files/common/files.js'; +import { InMemoryFileSystemProvider } from '../../../../../../../platform/files/common/inMemoryFilesystemProvider.js'; +import { IHoverService } from '../../../../../../../platform/hover/browser/hover.js'; +import { NullHoverService } from '../../../../../../../platform/hover/test/browser/nullHoverService.js'; +import { ILogService, NullLogService } from '../../../../../../../platform/log/common/log.js'; +import { TestFileService, workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js'; +import { ChatResourceGroupWidget } from '../../../../browser/widget/chatContentParts/chatResourceGroupWidget.js'; +import { IChatCollapsibleIODataPart } from '../../../../browser/widget/chatContentParts/chatToolInputOutputContentPart.js'; +import '../../../../browser/widget/media/chat.css'; + +suite('ChatResourceGroupWidget', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + const imageData = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a79cAAAAASUVORK5CYII='; + const resource = URI.file('/generated/image.png'); + let instantiationService: ReturnType; + let imageHovers: HTMLElement[]; + + setup(() => { + instantiationService = workbenchInstantiationService(undefined, store); + imageHovers = []; + instantiationService.stub(IHoverService, { + ...NullHoverService, + setupDelayedHover: (target, hoverOptions) => { + const options = typeof hoverOptions === 'function' ? hoverOptions() : hoverOptions; + if (target.classList.contains('image-attachment') && dom.isHTMLElement(options.content)) { + imageHovers.push(options.content); + } + return Disposable.None; + }, + }); + }); + + for (const { names, savedNames, caseSensitive = true } of [ + { names: ['image.png', 'image.png'], savedNames: ['image.png', 'image-2.png'] }, + { names: ['image.png', 'image.png', 'image-2.png'], savedNames: ['image.png', 'image-3.png', 'image-2.png'] }, + { names: ['image', 'image'], savedNames: ['image', 'image-2'] }, + { names: ['IMAGE.PNG', 'image.png'], savedNames: ['IMAGE.PNG', 'image.png'], caseSensitive: true }, + { names: ['IMAGE.PNG', 'image.png'], savedNames: ['IMAGE.PNG', 'image-2.png'], caseSensitive: false }, + ]) { + test(`saving a gallery preserves every resource with duplicate names (${names.join(', ')}, caseSensitive=${caseSensitive})`, async () => { + const fileService = store.add(new FileService(new NullLogService())); + store.add(fileService.registerProvider(Schemas.inMemory, store.add(new class extends InMemoryFileSystemProvider { + override get capabilities() { + return caseSensitive ? super.capabilities : super.capabilities & ~FileSystemProviderCapabilities.PathCaseSensitive; + } + }()))); + instantiationService.stub(IFileService, fileService); + const destination = URI.from({ scheme: Schemas.inMemory, path: '/saved' }); + await fileService.createFolder(destination); + instantiationService.stub(IFileDialogService, new class extends mock() { + override async defaultFilePath() { return destination; } + override async showOpenDialog() { return [destination]; } + }()); + const parts: IChatCollapsibleIODataPart[] = []; + for (const [index, name] of names.entries()) { + const uri = URI.from({ scheme: Schemas.inMemory, path: `/source-${index}/${name}` }); + await fileService.createFolder(dirname(uri)); + await fileService.writeFile(uri, VSBuffer.fromString(`image-${index}`)); + parts.push({ kind: 'data', uri, mimeType: 'image/png' }); + } + const command = CommandsRegistry.getCommand('chat.toolOutput.save'); + assert.ok(command); + await instantiationService.invokeFunction(accessor => command.handler(accessor, { parts })); + const saved = await fileService.resolve(destination); + const contents = await Promise.all((saved.children ?? []).map(async file => ({ + name: file.name, + value: (await fileService.readFile(file.resource)).value.toString(), + }))); + assert.deepStrictEqual(contents.sort((a, b) => a.name.localeCompare(b.name)), savedNames.map((name, index) => ({ + name, + value: `image-${index}`, + })).sort((a, b) => a.name.localeCompare(b.name))); + }); + } + + function render(parts: IChatCollapsibleIODataPart[], inline = true, animateImageReveal = false, imageDimensions?: ResourceMap): ChatResourceGroupWidget { + const host = dom.append(mainWindow.document.body, dom.$(animateImageReveal ? '.chat-image-generation-single' : 'div')); + const container = dom.append(host, dom.$(inline ? '.chat-generated-image-result' : 'div')); + const imageReveal = animateImageReveal ? { container: host } : undefined; + const widget = store.add(instantiationService.createInstance(ChatResourceGroupWidget, parts, inline ? { imagePresentation: 'inline', showImageInHover: false, imageReveal, imageDimensions } : undefined)); + container.appendChild(widget.domNode); + store.add(toDisposable(() => host.remove())); + return widget; + } + + function deferImageRead() { + const content = new DeferredPromise(); + const fileService = store.add(new class extends TestFileService { + override async readFile(uri: URI) { + const file = await super.readFile(uri); + return { ...file, value: await content.p }; + } + }()); + instantiationService.stub(IFileService, fileService); + return { content, fileService }; + } + + function snapshot(widget: ChatResourceGroupWidget) { + const attachment = widget.domNode.querySelector('.image-attachment'); + const image = attachment?.querySelector('img'); + return { + images: widget.domNode.querySelectorAll('img.chat-attached-context-pill-image').length, + filePills: widget.domNode.querySelectorAll('.chat-attached-context-attachment:not(.image-attachment)').length, + warnings: widget.domNode.querySelectorAll('.codicon-warning, .codicon-warning-compact').length, + busy: attachment?.getAttribute('aria-busy'), + error: attachment?.classList.contains('image-load-error'), + hasSource: !!image?.getAttribute('src'), + }; + } + + test('inline base64 images use the browser decoder without creating a JavaScript byte copy', async () => { + const canvas = mainWindow.document.createElement('canvas'); + canvas.width = 1200; + canvas.height = 600; + const base64Value = canvas.toDataURL('image/png').split(',')[1]; + const widget = render([{ kind: 'data', uri: resource, mimeType: 'image/png', base64Value }]); + const initial = snapshot(widget); + const image = widget.domNode.querySelector('img')!; + await image.decode(); + await retry(async () => assert.strictEqual(snapshot(widget).busy, 'false'), 10, 50); + + assert.deepStrictEqual({ + initial, + browserSource: image.src === `data:image/png;base64,${base64Value}`, + final: snapshot(widget), + dimensions: [image.naturalWidth, image.naturalHeight], + hoverImages: imageHovers.flatMap(hover => [...hover.querySelectorAll('img')]).length, + status: widget.domNode.querySelector('.chat-attached-context-image-status')?.textContent, + }, { + initial: { images: 1, filePills: 0, warnings: 0, busy: 'true', error: false, hasSource: true }, + browserSource: true, + final: { images: 1, filePills: 0, warnings: 0, busy: 'false', error: false, hasSource: true }, + dimensions: [1200, 600], + hoverImages: 0, + status: undefined, + }); + }); + + test('referenced images stay in a loading state and retain their image node when bytes arrive', async () => { + const { content, fileService } = deferImageRead(); + const widget = render([{ kind: 'data', uri: resource, mimeType: 'image/png' }]); + const initial = snapshot(widget); + const image = widget.domNode.querySelector('img')!; + const loadingBorder = mainWindow.getComputedStyle(widget.domNode.querySelector('.image-attachment')!).borderColor; + await content.complete(decodeBase64(imageData)); + // WebKit can finish decoding before the load handler updates the widget. + await retry(async () => assert.ok(image.complete && image.naturalWidth > 0 && snapshot(widget).busy === 'false'), 10, 50); + + assert.deepStrictEqual({ + initial, + loadingBorder, + final: snapshot(widget), + sameImage: widget.domNode.querySelector('img') === image, + reads: fileService.readOperations.map(read => read.resource.toString()), + }, { + initial: { images: 1, filePills: 0, warnings: 0, busy: 'true', error: false, hasSource: false }, + loadingBorder: 'rgba(0, 0, 0, 0)', + final: { images: 1, filePills: 0, warnings: 0, busy: 'false', error: false, hasSource: true }, + sameImage: true, + reads: [resource.toString()], + }); + }); + + for (const [width, height] of [[800, 1600], [1600, 800]]) { + test(`known image dimensions reserve responsive space until a ${width}x${height} image reloads`, async () => { + const canvas = dom.$('canvas', { width, height }); + const base64Value = canvas.toDataURL('image/png').split(',')[1]; + const imageDimensions = new ResourceMap(); + const first = render([{ kind: 'data', uri: resource, mimeType: 'image/png', base64Value }], true, false, imageDimensions); + const firstImage = first.domNode.querySelector('img')!; + await retry(async () => assert.ok(firstImage.complete && snapshot(first).busy === 'false'), 20, 50); + first.dispose(); + + const { content } = deferImageRead(); + const next = render([{ kind: 'data', uri: resource, mimeType: 'image/png' }], true, false, imageDimensions); + const host = next.domNode.parentElement!; + const image = next.domNode.querySelector('img')!; + const bounds = () => { + const rect = image.getBoundingClientRect(); + return [rect.width, rect.height]; + }; + host.style.width = '400px'; + const wide = bounds(); + host.style.width = '120px'; + const narrow = bounds(); + const pending = { + reserved: narrow[0] > 0 && narrow[1] > 0, + resized: narrow[0] < wide[0] && narrow[1] < wide[1], + visibility: mainWindow.getComputedStyle(image).visibility, + status: mainWindow.getComputedStyle(next.domNode.querySelector('.chat-attached-context-image-status')!).display, + busy: snapshot(next).busy, + }; + await content.complete(decodeBase64(base64Value)); + await retry(async () => assert.ok(image.complete && snapshot(next).busy === 'false'), 20, 50); + assert.deepStrictEqual({ + pending, + stableDimensions: bounds().every((value, index) => Math.abs(value - narrow[index]) <= 1), + naturalDimensions: imageDimensions.get(resource), + visibility: mainWindow.getComputedStyle(image).visibility, + sizeHintCleared: image.style.width === '' && image.style.maxWidth === '' && image.style.aspectRatio === '', + }, { + pending: { reserved: true, resized: true, visibility: 'hidden', status: 'none', busy: 'true' }, + stableDimensions: true, + naturalDimensions: { width, height }, + visibility: 'visible', + sizeHintCleared: true, + }); + }); + } + + test('a failed image reload clears remembered dimensions and shows its error', async () => { + const imageDimensions = new ResourceMap([[resource, { width: 800, height: 1200 }]]); + const { content } = deferImageRead(); + const widget = render([{ kind: 'data', uri: resource, mimeType: 'image/png' }], true, false, imageDimensions); + await content.error(new Error('The image is no longer available.')); + await retry(async () => assert.strictEqual(snapshot(widget).error, true), 10, 50); + const image = widget.domNode.querySelector('img')!; + const status = widget.domNode.querySelector('.chat-attached-context-image-status')!; + assert.deepStrictEqual({ + cached: imageDimensions.has(resource), + sizeHintCleared: image.style.width === '' && image.style.maxWidth === '' && image.style.aspectRatio === '', + visibleError: !!status.textContent && mainWindow.getComputedStyle(status).display !== 'none', + hiddenImage: mainWindow.getComputedStyle(image).display === 'none', + }, { cached: false, sizeHintCleared: true, visibleError: true, hiddenImage: true }); + }); + + test('a changed referenced image replaces its remembered natural dimensions', async () => { + const imageDimensions = new ResourceMap([[resource, { width: 800, height: 1200 }]]); + const { content } = deferImageRead(); + const widget = render([{ kind: 'data', uri: resource, mimeType: 'image/png' }], true, false, imageDimensions); + await content.complete(decodeBase64(imageData)); + await retry(async () => assert.strictEqual(snapshot(widget).busy, 'false'), 20, 50); + assert.deepStrictEqual(imageDimensions.get(resource), { width: 1, height: 1 }); + }); + + test('a slow referenced image does not delay embedded images in the same gallery', async () => { + const { content } = deferImageRead(); + const widget = render([ + { kind: 'data', uri: resource, mimeType: 'image/png' }, + { kind: 'data', uri: URI.file('/generated/embedded.png'), mimeType: 'image/png', base64Value: imageData }, + ]); + const images = [...widget.domNode.querySelectorAll('img')]; + assert.strictEqual(images.length, 2); + await images[1].decode(); + const beforeReferenceLoads = images.map(image => image.complete && image.naturalWidth > 0); + await content.complete(decodeBase64(imageData)); + await retry(async () => assert.ok(images[0].complete && images[0].naturalWidth > 0), 10, 50); + + assert.deepStrictEqual({ + beforeReferenceLoads, + sameImages: [...widget.domNode.querySelectorAll('img')].every((image, index) => image === images[index]), + }, { beforeReferenceLoads: [false, true], sameImages: true }); + }); + + test('the glyph band stays pending until referenced image bytes load', async () => { + const { content } = deferImageRead(); + const widget = render([{ kind: 'data', uri: resource, mimeType: 'image/png' }], true, true); + const container = widget.domNode.parentElement!; + container.classList.add('interactive-session'); + container.style.setProperty('--vscode-strokeThickness', '1px'); + const reveal = widget.domNode.querySelector('.chat-image-reveal')!; + const image = reveal.querySelector('img')!; + const initial = { pending: reveal.classList.contains('pending'), busy: snapshot(widget).busy, hasSource: !!image.getAttribute('src'), width: reveal.getBoundingClientRect().width }; + await content.complete(decodeBase64(imageData)); + await retry(async () => assert.ok(image.complete && image.naturalWidth > 0 && snapshot(widget).busy === 'false'), 10, 50); + + assert.deepStrictEqual({ + initial, + loaded: { pending: reveal.classList.contains('pending'), busy: snapshot(widget).busy, sameImage: reveal.querySelector('img') === image }, + }, { + initial: { pending: true, busy: 'true', hasSource: false, width: 320 }, + loaded: { pending: false, busy: 'false', sameImage: true }, + }); + }); + + for (const width of [320, 760]) { + test(`the glyph band stays painted while image bytes load and the reveal expands in a ${width}px container`, async () => { + instantiationService.stub(IAccessibilityService, new class extends TestAccessibilityService { + override isMotionReduced(): boolean { return false; } + }()); + const { content } = deferImageRead(); + const imageDimensions = new ResourceMap([[resource, { width: 400, height: 240 }]]); + const widget = render([{ kind: 'data', uri: resource, mimeType: 'image/png' }], true, true, imageDimensions); + const host = widget.domNode.parentElement!.parentElement!; + host.style.width = `${width}px`; + const reveal = widget.domNode.querySelector('.chat-image-reveal')!; + const field = reveal.querySelector('.chat-image-loading-glyphs')!; + const frame = () => new Promise(resolve => store.add(dom.scheduleAtNextAnimationFrame(mainWindow, () => resolve()))); + const bandState = () => { + const bounds = reveal.getBoundingClientRect(); + const visible = bounds.width > 0 && bounds.width <= width && bounds.height > 0; + let painted = false; + if (visible && field.clientWidth && field.clientHeight) { + const paintWidth = Math.min(field.width, Math.ceil(bounds.width * field.width / field.clientWidth)); + const paintHeight = Math.min(field.height, Math.ceil(bounds.height * field.height / field.clientHeight)); + const pixels = field.getContext('2d')!.getImageData(0, 0, paintWidth, paintHeight).data; + painted = pixels.some((value, index) => index % 4 === 3 && value > 0); + } + return { visible, painted }; + }; + await frame(); + await frame(); + const waiting = bandState(); + const waitingHeight = reveal.getBoundingClientRect().height; + const canvas = dom.$('canvas', { width: 400, height: 240 }); + const context = canvas.getContext('2d')!; + context.fillStyle = '#4682b4'; + context.fillRect(0, 0, canvas.width, canvas.height); + await content.complete(decodeBase64(canvas.toDataURL('image/png').split(',')[1])); + await retry(async () => assert.ok(reveal.classList.contains('revealing')), 20, 50); + const clock = field.getAnimations()[0]; + assert.ok(clock); + clock.pause(); + const states = []; + for (const fraction of [0, 0.05, 0.2, 0.5]) { + clock.currentTime = Number(clock.effect?.getTiming().duration) * fraction; + await frame(); + states.push(bandState()); + } + assert.deepStrictEqual({ + waiting, + waitingHeight, + sameField: reveal.querySelector('.chat-image-loading-glyphs') === field, + expansion: states, + }, { + waiting: { visible: true, painted: true }, + waitingHeight: 50, + sameField: true, + expansion: Array.from({ length: 4 }, () => ({ visible: true, painted: true })), + }); + }); + } + + test('an image read failure removes the glyph band and keeps the error visible', async () => { + const { content } = deferImageRead(); + const widget = render([{ kind: 'data', uri: resource, mimeType: 'image/png' }], true, true); + await content.error(new Error('The generated image file is unavailable.')); + await retry(async () => assert.strictEqual(snapshot(widget).error, true), 10, 50); + const status = widget.domNode.querySelector('.chat-attached-context-image-status')!; + + assert.deepStrictEqual({ + error: snapshot(widget).error, + pending: widget.domNode.querySelectorAll('.chat-image-reveal.pending, .chat-image-generation-line').length, + effects: widget.domNode.querySelectorAll('.chat-image-loading-glyphs').length, + visibleError: !!status.textContent && mainWindow.getComputedStyle(status).display !== 'none', + }, { error: true, pending: 0, effects: 0, visibleError: true }); + }); + + test('a missing referenced image reports a genuine load failure with its details', async () => { + const { content } = deferImageRead(); + const widget = render([{ kind: 'data', uri: resource, mimeType: 'image/png' }]); + await content.error(new Error('The generated image file is unavailable.')); + await timeout(0); + + assert.deepStrictEqual({ + state: snapshot(widget), + label: widget.domNode.querySelector('.image-attachment')?.ariaLabel, + status: widget.domNode.querySelector('.chat-attached-context-image-status')?.textContent, + hover: imageHovers[0].textContent, + }, { + state: { images: 1, filePills: 0, warnings: 0, busy: 'false', error: true, hasSource: false }, + label: 'Unable to load image: image.png', + status: 'Unable to load image: image.png', + hover: 'Unable to load image: image.png\nThe generated image file is unavailable.', + }); + }); + + for (const base64Value of ['invalid!base64', 'aW1hZ2U=', '']) { + test(`invalid inline image data shows a load error, not a file pill (${JSON.stringify(base64Value)})`, async () => { + const warnings: string[] = []; + instantiationService.stub(ILogService, new class extends NullLogService { + override warn(message: string) { warnings.push(message); } + }()); + const widget = render([{ kind: 'data', uri: resource, mimeType: 'image/png', base64Value }]); + await retry(async () => assert.ok(widget.domNode.querySelector('.image-load-error')), 10, 50); + + assert.deepStrictEqual({ + filePills: snapshot(widget).filePills, + busy: snapshot(widget).busy, + status: widget.domNode.querySelector('.chat-attached-context-image-status')?.textContent, + warnings, + }, { + filePills: 0, + busy: 'false', + status: 'Unable to load image: image.png', + warnings: [], + }); + }); + } + + test('disposing an inline image releases its object URL and load listeners', async () => { + const widget = render([{ kind: 'data', uri: resource, mimeType: 'image/png', value: decodeBase64(imageData).buffer }]); + const image = widget.domNode.querySelector('img')!; + await image.decode(); + const url = image.src; + assert.deepStrictEqual([...new Uint8Array(await (await fetch(url)).arrayBuffer())], [...decodeBase64(imageData).buffer]); + widget.dispose(); + const disposedMarkup = widget.domNode.innerHTML; + image.dispatchEvent(new Event('load')); + image.dispatchEvent(new Event('error')); + assert.strictEqual(widget.domNode.innerHTML, disposedMarkup); + await assert.rejects(() => fetch(url), TypeError); + }); + + test('disposing during a referenced image read prevents late rendering', async () => { + const { content } = deferImageRead(); + const widget = render([{ kind: 'data', uri: resource, mimeType: 'image/png' }]); + widget.dispose(); + const disposedMarkup = widget.domNode.innerHTML; + await content.complete(decodeBase64(imageData)); + await timeout(0); + assert.strictEqual(widget.domNode.innerHTML, disposedMarkup); + }); + + test('ordinary tool resources keep their deferred attachment thumbnails', async () => { + const widget = render([{ kind: 'data', uri: resource, mimeType: 'image/png', base64Value: imageData }], false); + const initial = snapshot(widget); + await retry(async () => { + const image = widget.domNode.querySelector('img'); + assert.ok(image?.complete && image.naturalWidth > 0); + }, 20, 50); + + assert.deepStrictEqual({ + initialFilePills: initial.filePills, + initialImages: initial.images, + finalImages: snapshot(widget).images, + hoverImages: imageHovers.flatMap(hover => [...hover.querySelectorAll('img')]).length, + }, { initialFilePills: 1, initialImages: 0, finalImages: 1, hoverImages: 1 }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolInputOutputContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolInputOutputContentPart.test.ts index c15bf5243d9d43..e8bc47b9d20c20 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolInputOutputContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolInputOutputContentPart.test.ts @@ -8,6 +8,7 @@ import { mainWindow } from '../../../../../../../base/browser/window.js'; import { Event } from '../../../../../../../base/common/event.js'; import { observableValue } from '../../../../../../../base/common/observable.js'; import { URI } from '../../../../../../../base/common/uri.js'; +import { mock } from '../../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js'; import { IChatToolInvocationSerialized, ToolConfirmKind } from '../../../../common/chatService/chatService.js'; @@ -78,6 +79,7 @@ suite('ChatCollapsibleInputOutputContentPart', () => { false, false, false, + undefined, )); const button = part.domNode.querySelector('.chat-confirmation-widget-title'); @@ -192,6 +194,58 @@ suite('ChatCollapsibleInputOutputContentPart', () => { }); }); + for (const toolId of ['image_gen.imagegen', 'image_generation', 'copilot_viewImage']) { + test(`collapsed resources are hidden only for image generation (${toolId})`, () => { + const instantiationService = workbenchInstantiationService(undefined, store); + const context: IChatContentPartRenderContext = { + element: new class extends mock() { + override readonly id = 'response'; + override readonly sessionResource = URI.parse('chat-session://test/session'); + }(), + elementIndex: 0, + container: mainWindow.document.createElement('div'), + content: [], + contentIndex: 0, + codeBlockStartIndex: 0, + treeStartIndex: 0, + inlineTextModels: new class extends mock() { }(), + editorPool: new class extends mock() { }(), + diffEditorPool: new class extends mock() { }(), + currentWidth: observableValue('testWidth', 500), + onDidChangeVisibility: Event.None, + }; + const toolInvocation: IChatToolInvocationSerialized = { + kind: 'toolInvocationSerialized', + toolCallId: 'image-call', + toolId, + invocationMessage: 'Image tool', + originMessage: undefined, + pastTenseMessage: 'Image tool completed', + isComplete: true, + isConfirmed: { type: ToolConfirmKind.ConfirmationNotNeeded }, + presentation: undefined, + source: ToolDataSource.Internal, + }; + const part = store.add(instantiationService.createInstance( + ChatInputOutputMarkdownProgressPart, + toolInvocation, + context, + 0, + 'Image tool completed', + undefined, + '{}', + undefined, + [{ type: 'embed', value: 'aW1hZ2U=', mimeType: 'image/png' }], + false, + )); + + assert.deepStrictEqual({ + collapsed: part.domNode.querySelector('.chat-confirmation-widget-title')?.getAttribute('aria-expanded'), + previewCount: part.domNode.querySelectorAll('.chat-collapsible-top-level-resource-group').length, + }, { collapsed: 'false', previewCount: toolId === 'copilot_viewImage' ? 1 : 0 }); + }); + } + test('uses output MIME types and defaults to plaintext', () => { const renderedCodeBlocks: { text: string; languageId: string }[] = []; const editorPool = Object.create(EditorPool.prototype) as EditorPool; diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts index 0a91d7fa6f83ca..77d3b69a642903 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts @@ -9,6 +9,7 @@ import * as dom from '../../../../../../base/browser/dom.js'; import { mainWindow } from '../../../../../../base/browser/window.js'; import { IAction, Separator } from '../../../../../../base/common/actions.js'; import { DeferredPromise, retry, timeout } from '../../../../../../base/common/async.js'; +import { decodeBase64 } from '../../../../../../base/common/buffer.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; @@ -18,14 +19,16 @@ import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { OffsetRange } from '../../../../../../editor/common/core/ranges/offsetRange.js'; import { Range } from '../../../../../../editor/common/core/range.js'; import { ICodeEditorService } from '../../../../../../editor/browser/services/codeEditorService.js'; +import { ITextResourcePropertiesService } from '../../../../../../editor/common/services/textResourceConfiguration.js'; import { IActionViewItemFactory, IActionViewItemService, NullActionViewItemService } from '../../../../../../platform/actions/browser/actionViewItemService.js'; import { IMenu, IMenuService, MenuId, MenuItemAction } from '../../../../../../platform/actions/common/actions.js'; -import { agentHostAuthority } from '../../../../../../platform/agentHost/common/agentHostUri.js'; +import { agentHostAuthority, toAgentHostContentUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { remoteAgentHostSessionTypeId } from '../../../../../../platform/agentHost/common/agentHostSessionType.js'; import { CLOUD_SANDBOX_AGENT_PROVIDER, cloudSandboxAddress } from '../../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; -import { ConfirmationOptionKind, McpServerStatus, ToolCallStatus } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { ConfirmationOptionKind, McpServerStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, type ToolCallCompletedState, type ToolCallRunningState } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { ConfigurationTarget, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { CommandsRegistry } from '../../../../../../platform/commands/common/commands.js'; +import { IFileService } from '../../../../../../platform/files/common/files.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../../../platform/contextview/browser/contextView.js'; @@ -37,13 +40,14 @@ import { URI } from '../../../../../../base/common/uri.js'; import { mock, upcastPartial } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { TestMenuService, workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; +import { TestFileService } from '../../../../../test/common/workbenchTestServices.js'; import { IViewDescriptorService } from '../../../../../common/views.js'; import { IChatOutputRendererService, RenderedOutputPart } from '../../../browser/chatOutputItemRenderer.js'; import { IChatResponseFileChangesService } from '../../../browser/chatResponseFileChangesService.js'; import { ChatTreeItem, IChatAccessibilityService, IChatListItemRendererOptions, IChatWidget, IChatWidgetService } from '../../../browser/chat.js'; import { getCompactCodicon } from '../../../browser/chatIcons.js'; import { IChatToolRiskAssessmentService } from '../../../browser/tools/chatToolRiskAssessmentService.js'; -import { toolCallStateToInvocation } from '../../../browser/agentSessions/agentHost/stateToProgressAdapter.js'; +import { completedToolCallToSerialized, finalizeToolInvocation, toolCallStateToInvocation, toolCallStateToPreparedInvocation, updateRunningToolSpecificData } from '../../../browser/agentSessions/agentHost/stateToProgressAdapter.js'; import { AcceptToolConfirmationActionId, registerChatToolActions, SkipToolConfirmationActionId } from '../../../browser/actions/chatToolActions.js'; import { buildPlanReviewProgressContent, ChatListItemRenderer, endsWithActiveSubagentContent, endsWithCompletedQuestionInteraction, formatCompletedResponseDisclosureLabel, formatResponseTokenStats, getCompletedResponseCollapseEndIndex, getFinalResponseStartIndex, getFinalResponseStartIndexAfterMovingResponseOutcomeTools, getPersistentActivityLabel, getPersistentBackgroundActivity, getPersistentProgressState, getTrailingProgressLabel, getVisibleCompletedResponseItemCount, getWorkingProgressRelevantParts, IChatListItemTemplate, isAnchorTarget, isBlockingToolState, isFinalResponseRendered, isWaitingForMcpServers, moveResponseOutcomeToolsAfterFinalResponse, reconcileChatItemHeight, renderChatRequestTimestamp, renderChatResponseDetails, shouldCollapseCompletedResponsePart, shouldCreateGroupedThinkingPart, shouldHideChatUserIdentity, shouldPinToolInvocationToThinking, shouldRenderInitialProgressiveContentImmediately, shouldScheduleInitialHeightChange, shouldShowFileChangesSummaryForSettings, shouldShowTurnPillsSummary, shouldStartNewCollapsedThinkingGroup } from '../../../browser/widget/chatListRenderer.js'; import { ChatWidget } from '../../../browser/widget/chatWidget.js'; @@ -64,7 +68,7 @@ import { formatChatRequestTimestamp, formatChatResponseDetails, formatElapsedTim import { CHAT_OPEN_AGENT_HOST_CHAT_COMMAND_ID, ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatProgressAnimation, ChatProgressVerbosity, CollapsedToolsDisplayMode, ThinkingDisplayMode } from '../../../common/constants.js'; import { IChatSessionsService, SessionType } from '../../../common/chatSessionsService.js'; import { ILanguageModelsService } from '../../../common/languageModels.js'; -import { ChatModel } from '../../../common/model/chatModel.js'; +import { ChatModel, ChatResponseResource } from '../../../common/model/chatModel.js'; import { ChatViewModel, IChatPendingDividerViewModel, IChatRendererContent, IChatResponseViewModel, IChatViewModel, isRequestVM, isResponseVM } from '../../../common/model/chatViewModel.js'; import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; import { ChatAgentService, IChatAgentService } from '../../../common/participants/chatAgents.js'; @@ -74,6 +78,7 @@ import { ILanguageModelToolsService, IPreparedToolInvocation, IToolData, IToolRe import { ILanguageModelToolsConfirmationService } from '../../../common/tools/languageModelToolsConfirmationService.js'; import { ChatEditorOptions, IChatEditorConfiguration } from '../../../browser/widget/chatOptions.js'; import { ChatToolInvocationPart, shouldRenderGeneratedImageResult, shouldRenderSessionCreatedResult } from '../../../browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.js'; +import { ChatImageGenerationProgressPart } from '../../../browser/widget/chatContentParts/toolInvocationParts/chatImageGenerationProgressPart.js'; import { getGeneratedImageResultParts, getGeneratedImageResultPartsFromContent } from '../../../browser/widget/chatContentParts/toolInvocationParts/chatGeneratedImageResultSubPart.js'; import { MockChatService } from '../../common/chatService/mockChatService.js'; import { IChatModelFeedbackSurveyService } from '../../../browser/feedbackSurvey/chatModelFeedbackSurveyService.js'; @@ -86,6 +91,12 @@ import { setARIAContainer } from '../../../../../../base/browser/ui/aria/aria.js import { EditorMarkdownCodeBlockRenderer } from '../../../../../../editor/browser/widget/markdownRenderer/browser/editorMarkdownCodeBlockRenderer.js'; import { EditSuggestionId } from '../../../../../../editor/common/textModelEditSource.js'; import { IAccessibleViewService } from '../../../../../../platform/accessibility/browser/accessibleView.js'; +import { IAccessibilityService } from '../../../../../../platform/accessibility/common/accessibility.js'; +import { TestAccessibilityService } from '../../../../../../platform/accessibility/test/common/testAccessibilityService.js'; +import { descriptionForeground, focusBorder, foreground } from '../../../../../../platform/theme/common/colorRegistry.js'; +import { ColorScheme } from '../../../../../../platform/theme/common/theme.js'; +import { IThemeService } from '../../../../../../platform/theme/common/themeService.js'; +import { TestColorTheme, TestThemeService } from '../../../../../../platform/theme/test/common/testThemeService.js'; import { IMarkdownRendererService } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; @@ -307,7 +318,7 @@ suite('ChatListRenderer', () => { }, { active: 1, completed: 3, lateNested: 0 }); }); - test('moves durable tool outcomes after the final response and before trailing adjuncts', () => { + test('keeps generated images in place while moving created-session outcomes after the final response', () => { const tool: IChatToolInvocationSerialized = { kind: 'toolInvocationSerialized', toolCallId: 'create-session', @@ -351,8 +362,8 @@ suite('ChatListRenderer', () => { content: moveResponseOutcomeToolsAfterFinalResponse(content), finalResponseStartIndex: getFinalResponseStartIndexAfterMovingResponseOutcomeTools(content), }, { - content: [firstStep, finalResponse, tool, generatedImage, trailingAdjunct], - finalResponseStartIndex: 1, + content: [firstStep, generatedImage, finalResponse, tool, trailingAdjunct], + finalResponseStartIndex: 2, }); }); @@ -469,7 +480,7 @@ suite('ChatListRenderer', () => { ]); }); - test('renders generated images as outcomes only after the response completes', () => { + test('renders generated images as soon as the tool completes', () => { assert.deepStrictEqual([ shouldRenderGeneratedImageResult('generatedImage', false), shouldRenderGeneratedImageResult('generatedImage', true), @@ -545,6 +556,44 @@ suite('ChatListRenderer', () => { path: '/tool/image-call-2/0/generated-image-2.png', }]); }); + + test('preserves referenced image URIs when combining generated images into a gallery', () => { + const sessionResource = URI.parse('agent-host://remote/session'); + const imageUri = toAgentHostContentUri(URI.parse('generated-images:/session/generated-image.png?version=1'), 'remote'); + const parts = getGeneratedImageResultPartsFromContent([{ + kind: 'toolInvocationSerialized', + toolCallId: 'image-call', + toolId: 'image_generation', + toolSpecificData: { kind: 'generatedImage' }, + invocationMessage: 'Generating images', + originMessage: undefined, + pastTenseMessage: 'Generated images', + presentation: undefined, + isConfirmed: true, + isComplete: true, + source: ToolDataSource.Internal, + resultDetails: { + input: '', + output: [ + { type: 'ref', uri: imageUri, mimeType: 'image/png' }, + { type: 'embed', value: 'aW1hZ2U=', mimeType: 'image/png' }, + { type: 'embed', value: 'Image description', mimeType: 'image/png', isText: true }, + { type: 'ref', uri: URI.file('/session/notes.txt'), mimeType: 'text/plain' }, + ], + }, + }], sessionResource); + + assert.deepStrictEqual(parts.map(part => ({ + uri: part.uri.toString(), + base64Value: part.base64Value, + })), [{ + uri: imageUri.toString(), + base64Value: undefined, + }, { + uri: ChatResponseResource.createUri(sessionResource, 'image-call', 1, 'generated-image-2.png').toString(), + base64Value: 'aW1hZ2U=', + }]); + }); }); }); @@ -1642,12 +1691,12 @@ suite('ChatListRenderer', () => { assert.deepStrictEqual({ whileStarting, afterStarting }, { whileStarting: true, afterStarting: false }); }); - function createPersistentProgressRenderer(options: { thinkingStyle?: ThinkingDisplayMode; progressVerbosity?: ChatProgressVerbosity; chatMode?: ChatModeKind; collapsedTools?: CollapsedToolsDisplayMode; dockPlanReview?: boolean; renderFooterActions?: boolean; sessionResource?: URI; rendererOptions?: IChatListItemRendererOptions; editingSession?: IChatEditingSession; chatWidgetService?: IChatWidgetService; requestText?: string } = {}) { + function createPersistentProgressRenderer(options: { thinkingStyle?: ThinkingDisplayMode; progressVerbosity?: ChatProgressVerbosity; chatMode?: ChatModeKind; collapsedTools?: CollapsedToolsDisplayMode; persistentProgress?: ChatProgressAnimation; dockPlanReview?: boolean; renderFooterActions?: boolean; sessionResource?: URI; rendererOptions?: IChatListItemRendererOptions; editingSession?: IChatEditingSession; chatWidgetService?: IChatWidgetService; requestText?: string } = {}) { const disposables = store.add(new DisposableStore()); const instantiationService = workbenchInstantiationService(undefined, disposables); instantiationService.stub(ILanguageModelsService, { onDidChangeLanguageModels: Event.None, lookupLanguageModel: () => undefined }); const configurationService = new TestConfigurationService(); - configurationService.setUserConfiguration(ChatConfiguration.PersistentProgress, ChatProgressAnimation.Draw); + configurationService.setUserConfiguration(ChatConfiguration.PersistentProgress, options.persistentProgress ?? ChatProgressAnimation.Draw); configurationService.setUserConfiguration(ChatConfiguration.PersistentProgressVerbosity, options.progressVerbosity ?? ChatProgressVerbosity.Verbose); configurationService.setUserConfiguration(ChatConfiguration.IncrementalRendering, false); configurationService.setUserConfiguration(ChatConfiguration.ThinkingStyle, options.thinkingStyle ?? ThinkingDisplayMode.Collapsed); @@ -2585,6 +2634,1100 @@ suite('ChatListRenderer', () => { }); } + for (const [toolName, success] of [ + ['image_generation', true], + ['image_generation', false], + ['image_gen.imagegen', true], + ['image_gen.imagegen', false], + ] as const) { + test(`persistent image progress for ${toolName} keeps the prompt accessible before ${success ? 'completion' : 'failure'} without another provider event`, async () => { + const { instantiationService, model, request, renderer, template, node } = createPersistentProgressRenderer(); + renderer.layout(600); + const prompt = '{"prompt":"Draw a puppy"}'; + const backendSession = URI.parse(`${toolName === 'image_generation' ? 'copilotcli' : 'codex'}:/image-progress`); + const toolCall: ToolCallRunningState = { + toolCallId: 'image-1', + toolName, + displayName: 'Generate Image', + invocationMessage: 'Generating image', + status: ToolCallStatus.Running, + confirmed: ToolCallConfirmationReason.NotNeeded, + toolInput: prompt, + }; + const tool = toolCallStateToInvocation(toolCall, undefined, backendSession, 'local'); + model.acceptResponseProgress(request, tool); + renderer.renderElement(node, 0, template); + await timeout(0); + + const footer = template.value.querySelector('.chat-working-progress'); + const toolPart = template.value.querySelector('.chat-tool-invocation-part'); + assert.ok(footer && toolPart); + const placeholder = toolPart.querySelector('.chat-image-generation-placeholder'); + const running = { + label: footer.textContent?.replace(/\u00a0/g, ' ').trim(), + hidden: toolPart.style.display === 'none', + hasDetails: !!toolPart.querySelector('.chat-confirmation-widget-title'), + placeholderLabel: placeholder?.getAttribute('aria-label'), + placeholderBusy: placeholder?.getAttribute('aria-busy'), + pictureIcon: !!toolPart.querySelector('.chat-tool-call-icon[class*="codicon-file-media"]'), + }; + const runningDropdown = toolPart.querySelector('.chat-confirmation-widget-title'); + assert.ok(runningDropdown); + runningDropdown.focus(); + runningDropdown.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, bubbles: true })); + const runningInput = instantiationService.get(ICodeEditorService).listCodeEditors() + .filter(editor => toolPart.contains(editor.getDomNode())).map(editor => editor.getValue()); + const runningExpanded = runningDropdown.getAttribute('aria-expanded'); + runningDropdown.click(); + tool.acceptProgress({ message: 'Rendering final pixels', progress: 0.5 }); + renderer.renderElement(node, 0, template); + const samePlaceholderDuringProgress = toolPart.querySelector('.chat-image-generation-placeholder') === placeholder; + const inlineProgressIndicators = toolPart.querySelectorAll('.monaco-progress-container, .codicon-modifier-spin, .shimmer-progress').length; + + const completed: ToolCallCompletedState = { + ...toolCall, + status: ToolCallStatus.Completed, + success, + pastTenseMessage: success ? 'Generated image' : '"Generate Image" failed', + content: success ? [{ + type: ToolResultContentType.EmbeddedResource, + data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a79cAAAAASUVORK5CYII=', + contentType: 'image/png', + }] : [], + error: success ? undefined : { message: 'Image generation returned no usable image.' }, + }; + finalizeToolInvocation(tool, completed, backendSession, 'local'); + await timeout(0); + const finished = { + label: footer.textContent?.replace(/\u00a0/g, ' ').trim(), + hidden: toolPart.style.display === 'none', + hasDetails: !!toolPart.querySelector('.chat-confirmation-widget-title'), + pictureIcon: !!toolPart.querySelector('.chat-tool-call-icon[class*="codicon-file-media"]'), + errorIcon: !!toolPart.querySelector('.chat-tool-call-icon[class*="codicon-error"]'), + hasPlaceholder: !!toolPart.querySelector('.chat-image-generation-placeholder'), + errorLabel: toolPart.querySelector('.chat-confirmation-widget-title p')?.textContent?.replace(/\u00a0/g, ' '), + hasGallery: !!toolPart.querySelector('.chat-generated-image-result'), + expanded: toolPart.querySelector('.chat-confirmation-widget-title')?.getAttribute('aria-expanded'), + }; + const dropdown = toolPart.querySelector('.chat-confirmation-widget-title'); + dropdown?.click(); + const finishedInputOutput = instantiationService.get(ICodeEditorService).listCodeEditors() + .filter(editor => toolPart.contains(editor.getDomNode())).map(editor => editor.getValue()); + const expandedWithDetails = dropdown?.getAttribute('aria-expanded'); + dropdown?.click(); + renderer.renderElement(node, 0, template); + if (success) { + await retry(async () => { + const image = template.value.querySelector('.chat-generated-image-result img'); + assert.ok(image?.complete && image.naturalWidth > 0); + }, 20, 50); + } + assert.deepStrictEqual({ + running, + runningInput, + runningExpanded, + finished, + samePlaceholderDuringProgress, + inlineProgressIndicators, + finishedInputOutput, + expandedWithDetails, + sameFooter: template.value.querySelector('.chat-working-progress') === footer, + responseStillRunning: !request.response?.isComplete, + previews: template.value.querySelectorAll('.chat-generated-image-result .chat-attached-context-pill-image').length, + collapsedPreviews: template.value.querySelectorAll('.chat-collapsible-top-level-resource-group').length, + }, { + running: { label: 'Mixing the colors', hidden: false, hasDetails: true, placeholderLabel: 'Generating image', placeholderBusy: 'true', pictureIcon: true }, + runningInput: [prompt], + runningExpanded: 'true', + finished: { label: 'Working', hidden: false, hasDetails: true, pictureIcon: success, errorIcon: !success, hasPlaceholder: false, errorLabel: success ? 'Generated image' : 'Generated image failed', hasGallery: success, expanded: 'false' }, + samePlaceholderDuringProgress: true, + inlineProgressIndicators: 0, + finishedInputOutput: success ? [prompt] : [prompt, 'Image generation returned no usable image.'], + expandedWithDetails: 'true', + sameFooter: true, + responseStillRunning: true, + previews: success ? 1 : 0, + collapsedPreviews: 0, + }); + request.response?.complete(); + renderer.renderElement(node, 0, template); + + const restored = createPersistentProgressRenderer(); + restored.model.acceptResponseProgress(restored.request, tool.toJSON()); + restored.request.response?.complete(); + restored.renderer.renderElement(restored.node, 0, restored.template); + restored.renderer.layout(600); + restored.template.value.querySelector('.chat-confirmation-widget-title')?.click(); + assert.deepStrictEqual({ + disclosures: restored.template.value.querySelectorAll('.chat-confirmation-widget-title').length, + galleries: restored.template.value.querySelectorAll('.chat-generated-image-result').length, + placeholders: restored.template.value.querySelectorAll('.chat-image-generation-placeholder').length, + toolIcons: restored.template.value.querySelectorAll('.chat-tool-call-icon').length, + resultDetails: IChatToolInvocation.resultDetails(tool), + prompt: restored.instantiationService.get(ICodeEditorService).listCodeEditors() + .filter(editor => restored.template.value.contains(editor.getDomNode())).map(editor => editor.getValue())[0], + }, { + disclosures: 1, + galleries: success ? 1 : 0, + placeholders: 0, + toolIcons: 1, + resultDetails: tool.toJSON().resultDetails, + prompt, + }); + }); + } + + for (const toolName of ['image_generation', 'image_gen.imagegen']) { + test(`${toolName} uses the final glyph preset and hands its loader into the reveal without replaying history`, async () => { + const { model, request, renderer, template, node } = createPersistentProgressRenderer(); + const backendSession = URI.parse('remote-provider:/image-session'); + const toolCall: ToolCallRunningState = { + toolCallId: 'image-reveal', + toolName, + displayName: 'Generate Image', + invocationMessage: 'Generating image', + status: ToolCallStatus.Running, + confirmed: ToolCallConfirmationReason.NotNeeded, + toolInput: '{"prompt":"Draw a puppy"}', + }; + const tool = toolCallStateToInvocation(toolCall, undefined, backendSession, 'remote'); + model.acceptResponseProgress(request, tool); + renderer.renderElement(node, 0, template); + const loader = template.value.querySelector('.chat-image-generation-line'); + assert.ok(loader); + const style = mainWindow.getComputedStyle(loader); + const glyph = loader.querySelector('.chat-image-loading-glyphs'); + assert.ok(glyph); + const preset = { width: style.width, height: style.height, glyphSize: mainWindow.getComputedStyle(glyph).fontSize }; + const dropdown = template.value.querySelector('.chat-confirmation-widget-title'); + const running = { + hasDropdown: !!dropdown, + loaderBeforeDropdown: !!loader && !!dropdown && !!(loader.compareDocumentPosition(dropdown) & Node.DOCUMENT_POSITION_FOLLOWING), + toolIcons: template.value.querySelectorAll('.chat-tool-call-icon').length, + }; + dropdown?.click(); + tool.acceptProgress({ message: 'Generating image' }); + const expanded = dropdown?.getAttribute('aria-expanded'); + const sameLoader = template.value.querySelector('.chat-image-generation-line') === loader; + + finalizeToolInvocation(tool, { + ...toolCall, + status: ToolCallStatus.Completed, + success: true, + pastTenseMessage: 'Generated image', + content: [{ type: ToolResultContentType.EmbeddedResource, data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a79cAAAAASUVORK5CYII=', contentType: 'image/png' }], + }, backendSession, 'remote'); + const hasReveal = !!template.value.querySelector('.chat-image-reveal'); + request.response?.complete(); + const restored = createPersistentProgressRenderer(); + restored.model.acceptResponseProgress(restored.request, tool.toJSON()); + restored.request.response?.complete(); + restored.renderer.renderElement(restored.node, 0, restored.template); + await timeout(0); + + assert.deepStrictEqual({ + preset, + running, + expanded, + sameLoader, + hasReveal, + restoredHasReveal: !!restored.template.value.querySelector('.chat-image-reveal'), + restoredHasDropdown: !!restored.template.value.querySelector('.chat-confirmation-widget-title'), + restoredImages: restored.template.value.querySelectorAll('.chat-generated-image-result img').length, + }, { + preset: { width: '320px', height: '50px', glyphSize: '10px' }, + running: { hasDropdown: true, loaderBeforeDropdown: false, toolIcons: 1 }, + expanded: 'true', + sameLoader: true, + hasReveal: true, + restoredHasReveal: false, + restoredHasDropdown: true, + restoredImages: 1, + }); + }); + } + + for (const persistentProgress of [ChatProgressAnimation.Off, ChatProgressAnimation.Draw]) { + test(`image model attribution updates the running dropdown and survives history with progress ${persistentProgress}`, async () => { + const { instantiationService, model, request, renderer, template, node } = createPersistentProgressRenderer({ persistentProgress }); + const backendSession = URI.parse('remote-provider:/opaque-session'); + const toolCall: ToolCallRunningState = { + toolCallId: 'image-model-1', + toolName: 'image_generation', + displayName: 'Generate Image', + invocationMessage: 'Generating image', + status: ToolCallStatus.Running, + confirmed: ToolCallConfirmationReason.NotNeeded, + toolInput: '{"prompt":"Draw a puppy"}', + }; + const tool = toolCallStateToInvocation(toolCall, undefined, backendSession, 'remote'); + model.acceptResponseProgress(request, tool); + renderer.renderElement(node, 0, template); + renderer.layout(280); + const placeholder = template.value.querySelector('.chat-image-generation-placeholder'); + assert.ok(placeholder); + const dropdown = template.value.querySelector('.chat-confirmation-widget-title'); + assert.ok(dropdown); + dropdown.click(); + const imageGeneration = { requestedModel: { id: 'image-preview', name: 'Image \\ [Preview](https://example.invalid) www.example.invalid user@example.invalid' } }; + const running = { ...toolCall, _meta: { 'vscode.imageGeneration': imageGeneration } }; + updateRunningToolSpecificData(tool, running, backendSession, 'remote'); + await timeout(0); + + const progressSelector = '.chat-confirmation-widget-title'; + const progress = { + text: template.value.querySelector(progressSelector)?.textContent?.replace(/\u00a0/g, ' ').trim(), + ariaLabel: placeholder.getAttribute('aria-label'), + samePlaceholder: template.value.querySelector('.chat-image-generation-placeholder') === placeholder, + sameDropdown: template.value.querySelector(progressSelector) === dropdown, + expanded: dropdown.getAttribute('aria-expanded'), + input: instantiationService.get(ICodeEditorService).listCodeEditors() + .filter(editor => template.value.contains(editor.getDomNode())).map(editor => editor.getValue()), + footer: template.value.querySelector('.chat-working-progress')?.textContent?.replace(/\u00a0/g, ' ').trim(), + shimmer: !!template.value.querySelector('.chat-tool-invocation-part .shimmer-progress'), + links: template.value.querySelectorAll(`${progressSelector} a`).length, + }; + dropdown.click(); + finalizeToolInvocation(tool, { + ...running, + status: ToolCallStatus.Completed, + success: true, + pastTenseMessage: 'Generated image', + content: [{ type: ToolResultContentType.EmbeddedResource, data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a79cAAAAASUVORK5CYII=', contentType: 'image/png' }], + }, backendSession, 'remote'); + await timeout(0); + const title = (value: HTMLElement) => ({ + text: value.querySelector('.chat-confirmation-widget-title p')?.textContent?.replace(/\u00a0/g, ' '), + links: value.querySelectorAll('.chat-confirmation-widget-title a').length, + }); + const completed = title(template.value); + request.response?.complete(); + const restored = createPersistentProgressRenderer({ persistentProgress }); + restored.model.acceptResponseProgress(restored.request, tool.toJSON()); + restored.request.response?.complete(); + restored.renderer.renderElement(restored.node, 0, restored.template); + const modelName = imageGeneration.requestedModel.name; + const expectedTitle = { text: `Generated image with ${modelName}`, links: 0 }; + assert.deepStrictEqual({ progress, completed, restored: title(restored.template.value) }, { + progress: { + text: `Using ${modelName} to generate an image`, + ariaLabel: 'Generating image', + samePlaceholder: true, + sameDropdown: true, + expanded: 'true', + input: [toolCall.toolInput], + footer: persistentProgress === ChatProgressAnimation.Off ? undefined : 'Mixing the colors', + shimmer: false, + links: 0, + }, + completed: expectedTitle, + restored: expectedTitle, + }); + }); + } + + for (const persistentProgress of [ChatProgressAnimation.Off, ChatProgressAnimation.Draw]) { + for (const expanded of [false, true]) { + test(`image tool icon alignment and spacing match running and completed states (progress=${persistentProgress}, expanded=${expanded})`, async () => { + const { disposables, instantiationService, model, request, container, renderer, template, node } = createPersistentProgressRenderer({ persistentProgress }); + configurePersistentProgressTypography(container, 13); + const imageLoaded = new DeferredPromise(); + disposables.add(toDisposable(() => { void imageLoaded.complete(); })); + instantiationService.stub(IFileService, disposables.add(new class extends TestFileService { + override async readFile(resource: URI) { + const file = await super.readFile(resource); + await imageLoaded.p; + return file; + } + }())); + const backendSession = URI.parse('remote-provider:/image-spacing-session'); + const toolCall: ToolCallRunningState = { + toolCallId: 'image-spacing', + toolName: 'image_generation', + displayName: 'Generate Image', + invocationMessage: 'Generating image', + status: ToolCallStatus.Running, + confirmed: ToolCallConfirmationReason.NotNeeded, + toolInput: '{"prompt":"Draw a puppy"}', + }; + const tool = toolCallStateToInvocation(toolCall, undefined, backendSession, 'remote'); + model.acceptResponseProgress(request, tool); + renderer.renderElement(node, 0, template); + if (expanded) { + template.value.querySelector('.chat-confirmation-widget-title')!.click(); + } + const geometry = () => { + const details = template.value.querySelector('.chat-confirmation-widget')!; + const band = template.value.querySelector('.chat-image-generation-line')!; + const icon = template.value.querySelector('.chat-tool-invocation-part > .chat-tool-call-icon')!; + return { + gap: Math.round(band.getBoundingClientRect().top - details.getBoundingClientRect().bottom), + iconOffset: band.getBoundingClientRect().left - icon.getBoundingClientRect().left, + }; + }; + const running = geometry(); + finalizeToolInvocation(tool, { + ...toolCall, + status: ToolCallStatus.Completed, + success: true, + pastTenseMessage: 'Generated image', + content: [{ type: ToolResultContentType.Resource, uri: 'generated-images:/spacing.png', contentType: 'image/png' }], + }, backendSession, 'remote'); + assert.deepStrictEqual({ + running, + completed: geometry(), + expanded: template.value.querySelector('.chat-confirmation-widget-title')?.getAttribute('aria-expanded'), + }, { running: { gap: 16, iconOffset: 0 }, completed: { gap: 16, iconOffset: 0 }, expanded: String(expanded) }); + }); + } + } + + for (const [toolId, width] of ['image_generation', 'image_gen.imagegen'].flatMap(toolId => [760, 320.5, 280].map(width => [toolId, width] as const))) { + test(`image tool icon alignment survives the reveal and restore (${toolId}, width=${width})`, async () => { + const { disposables, instantiationService, model, request, container, renderer, template, node } = createPersistentProgressRenderer(); + configurePersistentProgressTypography(container, 13); + container.style.width = `${width}px`; + container.classList.remove('monaco-reduce-motion'); + let reducedMotion = false; + const motionChanged = disposables.add(new Emitter()); + instantiationService.stub(IAccessibilityService, new class extends TestAccessibilityService { + override readonly onDidChangeReducedMotion = motionChanged.event; + override isMotionReduced() { return reducedMotion; } + }()); + const tool = new ChatToolInvocation({ + invocationMessage: 'Generating image', + pastTenseMessage: 'Generated image', + }, { + id: toolId, displayName: 'Generate Image', modelDescription: 'Generate Image', source: ToolDataSource.Internal, + }, 'image-alignment', undefined, { prompt: 'Draw a puppy' }, {}, request.id); + model.acceptResponseProgress(request, tool); + renderer.renderElement(node, 0, template); + const offset = (root: HTMLElement, selector: string) => { + const visual = root.querySelector(selector)!; + const icon = root.querySelector('.chat-tool-invocation-part > .chat-tool-call-icon')!; + return visual.getBoundingClientRect().left - icon.getBoundingClientRect().left; + }; + const titleLeft = template.value.querySelector('.chat-confirmation-widget-title')!.getBoundingClientRect().left; + const runningOffset = offset(template.value, '.chat-image-generation-line'); + const canvas = dom.$('canvas', { width: 1536, height: 1024 }); + const context = canvas.getContext('2d')!; + context.fillStyle = '#4682b4'; + context.fillRect(0, 0, canvas.width, canvas.height); + await tool.didExecuteTool({ + content: [], + toolSpecificData: { kind: 'generatedImage' }, + toolResultDetails: { + input: '{"prompt":"Draw a puppy"}', + output: [{ type: 'embed', value: canvas.toDataURL('image/png').split(',')[1], mimeType: 'image/png' }], + }, + }); + await retry(async () => assert.ok(template.value.querySelector('.chat-image-reveal.revealing')), 10, 50); + const image = template.value.querySelector('.chat-generated-image-result img')!; + const field = template.value.querySelector('.chat-image-loading-glyphs')!; + const clock = field.getAnimations()[0]; + assert.ok(clock); + clock.pause(); + const revealOffsets = []; + for (const fraction of [0, 0.5, 1]) { + clock.currentTime = Number(clock.effect?.getTiming().duration) * fraction; + await new Promise(resolve => disposables.add(dom.scheduleAtNextAnimationFrame(mainWindow, () => resolve()))); + revealOffsets.push(offset(template.value, '.chat-image-reveal')); + } + const imageSize = () => [image.getBoundingClientRect().width, image.getBoundingClientRect().height]; + const finalFrameSize = imageSize(); + const save = template.value.querySelector('.chat-collapsible-io-resource-actions')!; + const saveOffset = () => save.getBoundingClientRect().left - image.getBoundingClientRect().right; + const finalSaveOffset = saveOffset(); + reducedMotion = true; + motionChanged.fire(); + const completedOffset = offset(template.value, '.chat-generated-image-result img'); + request.response?.complete(); + const restored = createPersistentProgressRenderer(); + configurePersistentProgressTypography(restored.container, 13); + restored.container.style.width = `${width}px`; + restored.model.acceptResponseProgress(restored.request, tool.toJSON()); + restored.request.response?.complete(); + restored.renderer.renderElement(restored.node, 0, restored.template); + const restoredImage = restored.template.value.querySelector('.chat-generated-image-result img')!; + await restoredImage.decode(); + await retry(async () => assert.strictEqual(restoredImage.closest('.image-attachment')?.getAttribute('aria-busy'), 'false'), 10, 50); + const restoredSize = restoredImage.getBoundingClientRect(); + assert.deepStrictEqual({ + runningOffset, + revealOffsets, + completedOffset, + restoredOffset: offset(restored.template.value, '.chat-generated-image-result img'), + titleUnchanged: template.value.querySelector('.chat-confirmation-widget-title')!.getBoundingClientRect().left === titleLeft, + sizeUnchanged: imageSize(), + // Wrapped and bare images can differ by one CSS layout unit in intrinsic width. + restoredWidthMatches: Math.abs(restoredSize.width - finalFrameSize[0]) <= 1 / 64, + restoredHeight: restoredSize.height, + saveOffset: saveOffset(), + restoredReveal: !!restored.template.value.querySelector('.chat-image-loading-glyphs'), + }, { + runningOffset: 0, + revealOffsets: [0, 0, 0], + completedOffset: 0, + restoredOffset: 0, + titleUnchanged: true, + sizeUnchanged: finalFrameSize, + restoredWidthMatches: true, + restoredHeight: finalFrameSize[1], + saveOffset: finalSaveOffset, + restoredReveal: false, + }); + }); + } + + for (const eol of ['\n', '\r\n']) { + test(`image prompt streaming and execution keep the dropdown expanded without restarting the canvas (EOL=${JSON.stringify(eol)})`, async () => { + const { disposables, instantiationService, model, request, renderer, template, node } = createPersistentProgressRenderer(); + const getEol = sinon.stub(instantiationService.get(ITextResourcePropertiesService), 'getEOL').returns(eol); + disposables.add(toDisposable(() => getEol.restore())); + const tool = ChatToolInvocation.createStreaming({ + toolId: 'image_generation', + toolCallId: 'streaming-image', + toolData: { id: 'image_generation', displayName: 'Generate Image', modelDescription: 'Generate Image', source: ToolDataSource.Internal }, + }); + tool.updatePartialInput({ prompt: 'Draw a' }); + model.acceptResponseProgress(request, tool); + renderer.renderElement(node, 0, template); + const canvas = template.value.querySelector('canvas.chat-image-loading-glyphs'); + const dropdown = () => template.value.querySelector('.chat-confirmation-widget-title'); + const editors = () => instantiationService.get(ICodeEditorService).listCodeEditors() + .filter(editor => template.value.contains(editor.getDomNode())); + const input = () => editors().map(editor => editor.getValue({ preserveBOM: false, lineEnding: '\n' })); + dropdown()?.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', keyCode: 32, bubbles: true })); + const streamingEol = editors()[0]?.getModel()?.getEOL(); + const streamingInput = input(); + tool.updatePartialInput({ prompt: 'Draw a puppy' }); + const updatedInput = input(); + const running: ToolCallRunningState = { + toolCallId: tool.toolCallId, + toolName: tool.toolId, + displayName: 'Generate Image', + invocationMessage: 'Generating image', + status: ToolCallStatus.Running, + confirmed: ToolCallConfirmationReason.NotNeeded, + toolInput: '{"prompt":"Draw a puppy"}', + _meta: { 'vscode.imageGeneration': { requestedModel: { id: 'image-model', name: 'Image Model' } } }, + }; + const backend = URI.parse('remote-images:/opaque-session'); + tool.transitionFromStreaming(toolCallStateToPreparedInvocation(running, backend, 'remote'), { prompt: 'Draw a puppy' }, { type: ToolConfirmKind.ConfirmationNotNeeded }); + updateRunningToolSpecificData(tool, running, backend, 'remote'); + assert.deepStrictEqual({ + streamingEol, + streamingInput, + updatedInput, + runningInput: input(), + expanded: dropdown()?.getAttribute('aria-expanded'), + title: dropdown()?.textContent?.replace(/\u00a0/g, ' ').trim(), + sameCanvas: canvas === template.value.querySelector('canvas.chat-image-loading-glyphs'), + shimmer: !!template.value.querySelector('.chat-tool-invocation-part .shimmer-progress'), + }, { + streamingEol: eol, + streamingInput: [JSON.stringify({ prompt: 'Draw a' }, null, 2)], + updatedInput: [JSON.stringify({ prompt: 'Draw a puppy' }, null, 2)], + runningInput: [running.toolInput], + expanded: 'true', + title: 'Using Image Model to generate an image', + sameCanvas: true, + shimmer: false, + }); + request.response?.complete(); + }); + } + + for (const focus of ['collapsed', 'expanded', 'editor'] as const) { + test(`streaming image prompts preserve the ${focus} control and focus`, () => { + const { instantiationService, model, request, renderer, template, node } = createPersistentProgressRenderer(); + const tool = ChatToolInvocation.createStreaming({ + toolId: 'image_generation', + toolCallId: 'focused-image', + toolData: { id: 'image_generation', displayName: 'Generate Image', modelDescription: 'Generate Image', source: ToolDataSource.Internal }, + }); + tool.updatePartialInput({ prompt: 'Draw a' }); + model.acceptResponseProgress(request, tool); + renderer.renderElement(node, 0, template); + const dropdown = template.value.querySelector('.chat-confirmation-widget-title')!; + if (focus !== 'collapsed') { + dropdown.click(); + } + const editors = () => instantiationService.get(ICodeEditorService).listCodeEditors().filter(editor => template.value.contains(editor.getDomNode())); + const editor = editors()[0]; + const editorModel = editor?.getModel(); + if (focus === 'editor') { + editor.setSelection(new Range(2, 4, 2, 10)); + editor.focus(); + } else { + dropdown.focus(); + } + const selection = editor?.getSelection(); + const focusedElement = mainWindow.document.activeElement; + tool.updatePartialInput({ prompt: 'Draw a puppy' }); + assert.deepStrictEqual({ + sameDropdown: template.value.querySelector('.chat-confirmation-widget-title') === dropdown, + sameEditor: editors()[0] === editor, + sameModel: editors()[0]?.getModel() === editorModel, + sameFocus: mainWindow.document.activeElement === focusedElement, + selection: editors()[0]?.getSelection(), + input: editors()[0]?.getValue({ preserveBOM: false, lineEnding: '\n' }), + }, { + sameDropdown: true, + sameEditor: true, + sameModel: true, + sameFocus: true, + selection, + input: focus === 'collapsed' ? undefined : JSON.stringify({ prompt: 'Draw a puppy' }, null, 2), + }); + request.response?.complete(); + }); + } + + for (const toolName of ['image_generation', 'image_gen.imagegen']) { + for (const reason of [ToolCallCancellationReason.Skipped, ToolCallCancellationReason.Denied]) { + test(`executing ${toolName} cancellation stays distinct from failure in live and restored UI (${reason})`, () => { + const { model, request, renderer, template, node } = createPersistentProgressRenderer(); + const backend = URI.parse('remote-images:/cancelled'); + const running: ToolCallRunningState = { + toolCallId: 'cancelled-image', + toolName, + displayName: 'Generate Image', + invocationMessage: 'Generating image', + toolInput: '{"prompt":"Draw a puppy"}', + status: ToolCallStatus.Running, + confirmed: ToolCallConfirmationReason.NotNeeded, + }; + const tool = toolCallStateToInvocation(running, undefined, backend, 'remote'); + model.acceptResponseProgress(request, tool); + renderer.renderElement(node, 0, template); + const cancelled = { ...running, status: ToolCallStatus.Cancelled, reason } as const; + finalizeToolInvocation(tool, cancelled, backend, 'remote'); + const snapshot = (value: HTMLElement) => ({ + title: value.querySelector('.chat-confirmation-widget-title p')?.textContent?.replace(/\u00a0/g, ' '), + error: !!value.querySelector('.chat-tool-call-error, .chat-confirmation-widget-title .codicon-error-compact'), + images: value.querySelectorAll('.chat-generated-image-result img').length, + loading: value.querySelectorAll('.chat-image-generation-placeholder').length, + }); + const live = snapshot(template.value); + request.response?.complete(); + const restored = createPersistentProgressRenderer(); + restored.model.acceptResponseProgress(restored.request, completedToolCallToSerialized(cancelled, undefined, backend, 'remote')); + restored.request.response?.complete(); + restored.renderer.renderElement(restored.node, 0, restored.template); + const expected = { title: 'Image generation cancelled', error: false, images: 0, loading: 0 }; + assert.deepStrictEqual({ live, restored: snapshot(restored.template.value) }, { live: expected, restored: expected }); + }); + } + } + + test('subagent image completion preserves the parent gallery and renders its own images', async () => { + const { model, request, renderer, template, node } = createPersistentProgressRenderer({ + chatMode: ChatModeKind.Agent, + collapsedTools: CollapsedToolsDisplayMode.Always, + }); + const backend = URI.parse('remote-images:/subagent'); + const imageData = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a79cAAAAASUVORK5CYII='; + const createCall = (toolCallId: string): ToolCallRunningState => ({ + toolCallId, + toolName: 'image_generation', + displayName: 'Generate Image', + invocationMessage: 'Generating image', + toolInput: '{"prompt":"Draw a puppy"}', + status: ToolCallStatus.Running, + confirmed: ToolCallConfirmationReason.NotNeeded, + }); + const parentCall = createCall('parent-image'); + const parent = toolCallStateToInvocation(parentCall, undefined, backend, 'remote'); + model.acceptResponseProgress(request, parent); + renderer.renderElement(node, 0, template); + const complete = (tool: ChatToolInvocation, call: ToolCallRunningState) => finalizeToolInvocation(tool, { + ...call, + status: ToolCallStatus.Completed, + success: true, + pastTenseMessage: 'Generated image', + content: [{ type: ToolResultContentType.EmbeddedResource, data: imageData, contentType: 'image/png' }], + }, backend, 'remote'); + complete(parent, parentCall); + renderer.renderElement(node, 0, template); + const rootImages = () => [...template.value.querySelectorAll('.chat-generated-image-result img')].filter(image => !image.closest('.chat-subagent-part')).length; + const beforeSubagent = rootImages(); + const subagent = new ChatToolInvocation({ + invocationMessage: 'Delegating work', + pastTenseMessage: 'Delegated work', + toolSpecificData: { kind: 'subagent', description: 'Draw another image', isActive: true }, + }, { + id: 'task', displayName: 'Task', modelDescription: 'Delegate work', source: ToolDataSource.Internal, + }, 'image-subagent', undefined, {}); + model.acceptResponseProgress(request, subagent); + renderer.renderElement(node, 0, template); + const subagentPart = template.renderedParts?.find(part => part instanceof ChatSubagentContentPart); + assert.ok(subagentPart instanceof ChatSubagentContentPart); + const childCall = createCall('child-image'); + const child = toolCallStateToInvocation(childCall, subagent.toolCallId, backend, 'remote'); + model.acceptResponseProgress(request, child); + renderer.renderElement(node, 0, template); + complete(child, childCall); + model.acceptResponseProgress(request, { kind: 'markdownContent', content: new MarkdownString('Continuing the response.') }); + renderer.renderElement(node, 0, template); + const afterComparison = rootImages(); + subagentPart.domNode.querySelector('.chat-subagent-pill-content')?.click(); + await timeout(0); + const afterExpansion = { root: rootImages(), all: template.value.querySelectorAll('.chat-generated-image-result img').length }; + const secondChildCall = createCall('second-child-image'); + const secondChild = toolCallStateToInvocation(secondChildCall, subagent.toolCallId, backend, 'remote'); + model.acceptResponseProgress(request, secondChild); + renderer.renderElement(node, 0, template); + complete(secondChild, secondChildCall); + renderer.renderElement(node, 0, template); + const afterSecondChild = { root: rootImages(), all: template.value.querySelectorAll('.chat-generated-image-result img').length }; + request.response?.complete(); + const restored = createPersistentProgressRenderer({ chatMode: ChatModeKind.Agent, collapsedTools: CollapsedToolsDisplayMode.Always }); + for (const tool of [parent, subagent, child, secondChild]) { + restored.model.acceptResponseProgress(restored.request, tool.toJSON()); + } + restored.request.response?.complete(); + restored.renderer.renderElement(restored.node, 0, restored.template); + const restoredSubagent = restored.template.renderedParts?.find(part => part instanceof ChatSubagentContentPart); + assert.ok(restoredSubagent instanceof ChatSubagentContentPart); + restoredSubagent.domNode.querySelector('.chat-subagent-pill-content')?.click(); + await timeout(0); + assert.deepStrictEqual({ + beforeSubagent, + afterComparison, + afterExpansion, + afterSecondChild, + restoredImages: restored.template.value.querySelectorAll('.chat-generated-image-result img').length, + }, { + beforeSubagent: 1, + afterComparison: 1, + afterExpansion: { root: 1, all: 2 }, + afterSecondChild: { root: 1, all: 3 }, + restoredImages: 3, + }); + }); + + for (const success of [true, false]) { + test(`image generation shows its ${success ? 'completed' : 'failed'} dropdown instead of the last progress message`, async () => { + const { model, request, renderer, template, node } = createPersistentProgressRenderer(); + const backend = URI.parse('remote-images:/completed'); + const running: ToolCallRunningState = { + toolCallId: 'image', + toolName: 'image_generation', + displayName: 'Generate Image', + invocationMessage: 'Generating image', + status: ToolCallStatus.Running, + confirmed: ToolCallConfirmationReason.NotNeeded, + toolInput: '{"prompt":"Draw a puppy"}', + }; + const tool = toolCallStateToInvocation(running, undefined, backend, 'remote'); + model.acceptResponseProgress(request, tool); + renderer.renderElement(node, 0, template); + tool.acceptProgress({ message: 'Rendering the last pixels', progress: 0.9 }); + finalizeToolInvocation(tool, { + ...running, + status: ToolCallStatus.Completed, + success, + pastTenseMessage: 'Generated image', + content: success ? [{ type: ToolResultContentType.EmbeddedResource, data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a79cAAAAASUVORK5CYII=', contentType: 'image/png' }] : [], + error: success ? undefined : { message: 'Image request failed' }, + }, backend, 'remote'); + renderer.renderElement(node, 0, template); + const snapshot = (value: HTMLElement) => ({ + title: value.querySelector('.chat-confirmation-widget-title p')?.textContent?.replace(/\u00a0/g, ' '), + errorIcon: !!value.querySelector('.chat-confirmation-widget-title .codicon-error-compact'), + images: value.querySelectorAll('.chat-generated-image-result img').length, + waves: value.querySelectorAll('.chat-image-generation-placeholder').length, + progressText: value.textContent?.includes('Rendering the last pixels'), + }); + const live = snapshot(template.value); + request.response?.complete(); + const restored = createPersistentProgressRenderer(); + restored.model.acceptResponseProgress(restored.request, tool.toJSON()); + restored.request.response?.complete(); + restored.renderer.renderElement(restored.node, 0, restored.template); + const expected = { title: success ? 'Generated image' : 'Generated image failed', errorIcon: !success, images: success ? 1 : 0, waves: 0, progressText: false }; + assert.deepStrictEqual({ live, restored: snapshot(restored.template.value) }, { live: expected, restored: expected }); + }); + } + + for (const firstToFinish of [0, 1]) { + test(`two successful image calls keep both images and cached geometry when call ${firstToFinish} finishes first`, async () => { + const { model, request, container, renderer, template, node } = createPersistentProgressRenderer(); + configurePersistentProgressTypography(container, 13); + const images = [0, 1].map(index => { + const canvas = dom.$('canvas', { width: 800 + index * 100, height: 1200 }); + return canvas.toDataURL('image/png').split(',')[1]; + }); + const tools = images.map((_, index) => new ChatToolInvocation({ + invocationMessage: 'Generating image', + pastTenseMessage: 'Generated image', + }, { + id: 'image_generation', displayName: 'Generate Image', modelDescription: 'Generate Image', source: ToolDataSource.Internal, + }, `gallery-image-${index}`, undefined, {}, {}, request.id)); + for (const tool of tools) { + model.acceptResponseProgress(request, tool); + } + renderer.renderElement(node, 0, template); + const counts: number[] = []; + let reservedKnownImage = false; + for (const [step, index] of [firstToFinish, 1 - firstToFinish].entries()) { + await tools[index].didExecuteTool({ + content: [], + toolSpecificData: { kind: 'generatedImage' }, + toolResultDetails: { input: '{}', output: [{ type: 'embed', value: images[index], mimeType: 'image/png' }] }, + }); + renderer.renderElement(node, 0, template); + const galleryImages = [...template.value.querySelectorAll('.chat-generated-image-result img')]; + counts.push(galleryImages.length); + if (step === 1) { + const knownImage = galleryImages[firstToFinish]; + reservedKnownImage = !!knownImage && knownImage.getBoundingClientRect().height > 0 && knownImage.style.width !== ''; + } + await Promise.all(galleryImages.map(image => image.decode())); + await retry(async () => assert.ok(galleryImages.every(image => image.closest('.image-attachment')?.getAttribute('aria-busy') === 'false')), 10, 50); + } + assert.deepStrictEqual({ + counts, + reservedKnownImage, + galleries: template.value.querySelectorAll('.chat-generated-image-result').length, + sourceMatches: [...template.value.querySelectorAll('.chat-generated-image-result img')].map((image, index) => image.src === `data:image/png;base64,${images[index]}`), + galleryIconOffset: template.value.querySelector('.chat-generated-image-result')!.getBoundingClientRect().left + - template.value.querySelector('.chat-generated-image-result')!.closest('.chat-tool-invocation-part')!.querySelector(':scope > .chat-tool-call-icon')!.getBoundingClientRect().left, + }, { + counts: [1, 2], + reservedKnownImage: true, + galleries: 1, + sourceMatches: [true, true], + galleryIconOffset: 0, + }); + }); + } + + for (const toolName of ['image_generation', 'image_gen.imagegen']) { + for (const firstToFinish of [0, 1]) { + for (const firstResultSuccess of [true, false]) { + test(`overlapping image generations keep one placeholder and reveal terminal dropdowns for ${toolName} when call ${firstToFinish} ${firstResultSuccess ? 'succeeds' : 'fails'} first`, async () => { + const { model, request, renderer, template, node } = createPersistentProgressRenderer(); + const backendSession = URI.parse(`${toolName === 'image_generation' ? 'copilotcli' : 'codex'}:/overlapping-images`); + const calls: ToolCallRunningState[] = [0, 1].map(index => ({ + toolCallId: `image-${index}`, + toolName, + displayName: 'Generate Image', + invocationMessage: 'Generating image', + status: ToolCallStatus.Running, + confirmed: ToolCallConfirmationReason.NotNeeded, + toolInput: '{}', + })); + const tools = calls.map(call => toolCallStateToInvocation(call, undefined, backendSession, 'local')); + const snapshot = (value = template.value) => ({ + disclosures: value.querySelectorAll('.chat-confirmation-widget-title').length, + placeholders: value.querySelectorAll('.chat-image-generation-placeholder').length, + galleries: value.querySelectorAll('.chat-generated-image-result').length, + failures: [...value.querySelectorAll('.chat-confirmation-widget-title p')].map(title => title.textContent?.replace(/\u00a0/g, ' ')).filter(title => title === 'Generated image failed'), + progress: value.querySelector('.chat-working-progress')?.textContent?.replace(/\u00a0/g, ' ').trim(), + }); + const finish = (index: number, success: boolean) => finalizeToolInvocation(tools[index], { + ...calls[index], + status: ToolCallStatus.Completed, + success, + pastTenseMessage: success ? 'Generated image' : 'Generated image failed', + content: success ? [{ + type: ToolResultContentType.EmbeddedResource, + data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a79cAAAAASUVORK5CYII=', + contentType: 'image/png', + }] : [], + error: success ? undefined : { message: 'Image generation returned no usable image.' }, + }, backendSession, 'local'); + + model.acceptResponseProgress(request, tools[0]); + renderer.renderElement(node, 0, template); + const placeholder = template.value.querySelector('.chat-image-generation-placeholder'); + const observations = [snapshot()]; + model.acceptResponseProgress(request, tools[1]); + renderer.renderElement(node, 0, template); + observations.push(snapshot()); + const samePlaceholderOnOverlap = template.value.querySelector('.chat-image-generation-placeholder') === placeholder; + + finish(firstToFinish, firstResultSuccess); + await timeout(0); + observations.push(snapshot()); + const remainingPlaceholder = template.value.querySelector('.chat-image-generation-placeholder'); + tools[1 - firstToFinish].acceptProgress({ message: 'Generating image' }); + renderer.renderElement(node, 0, template); + observations.push(snapshot()); + const samePlaceholderOnProgress = template.value.querySelector('.chat-image-generation-placeholder') === remainingPlaceholder; + + finish(1 - firstToFinish, !firstResultSuccess); + await timeout(0); + observations.push(snapshot()); + renderer.renderElement(node, 0, template); + await retry(async () => { + const image = template.value.querySelector('.chat-generated-image-result img'); + assert.ok(image?.complete && image.naturalWidth > 0); + }, 20, 50); + observations.push(snapshot()); + request.response?.complete(); + renderer.renderElement(node, 0, template); + observations.push(snapshot()); + + const restored = createPersistentProgressRenderer(); + for (const tool of tools) { + restored.model.acceptResponseProgress(restored.request, tool.toJSON()); + } + restored.request.response?.complete(); + restored.renderer.renderElement(restored.node, 0, restored.template); + + const generating = { disclosures: 2, placeholders: 1, galleries: 0, failures: [], progress: 'Mixing the colors' }; + const partlyFinished = { disclosures: 2, placeholders: 1, galleries: firstResultSuccess ? 1 : 0, failures: firstResultSuccess ? [] : ['Generated image failed'], progress: 'Mixing the colors' }; + const finished = { disclosures: 2, placeholders: 0, galleries: 1, failures: ['Generated image failed'], progress: 'Working' }; + assert.deepStrictEqual({ + observations, + samePlaceholderOnOverlap, + samePlaceholderOnProgress, + complete: tools.map(tool => IChatToolInvocation.isComplete(tool)), + restored: snapshot(restored.template.value), + }, { + observations: [{ ...generating, disclosures: 1 }, generating, partlyFinished, partlyFinished, finished, finished, { ...finished, progress: undefined }], + samePlaceholderOnOverlap: true, + samePlaceholderOnProgress: true, + complete: [true, true], + restored: { ...finished, progress: undefined }, + }); + }); + } + } + } + + for (const toolId of ['image_generation', 'image_gen.imagegen']) { + for (const persistentProgress of [ChatProgressAnimation.Off, ChatProgressAnimation.Draw]) { + test(`overlapping image generations for ${toolId} retain cancelled dropdowns with persistent progress ${persistentProgress}`, async () => { + const { model, request, renderer, template, node } = createPersistentProgressRenderer({ persistentProgress, collapsedTools: CollapsedToolsDisplayMode.Always }); + const tools = [0, 1, 2].map(index => ChatToolInvocation.createStreaming({ + toolId, + toolCallId: `image-${index}`, + chatRequestId: request.id, + toolData: { id: toolId, displayName: 'Generate Image', modelDescription: 'Generate Image', source: ToolDataSource.Internal }, + })); + for (const tool of tools) { + model.acceptResponseProgress(request, tool); + renderer.renderElement(node, 0, template); + } + const placeholder = template.value.querySelector('.chat-image-generation-placeholder'); + const counts = [template.value.querySelectorAll('.chat-confirmation-widget-title').length]; + tools[1].cancelFromStreaming({ type: ToolConfirmKind.Denied }, 'Image generation cancelled'); + counts.push(template.value.querySelectorAll('.chat-confirmation-widget-title').length); + const samePlaceholder = template.value.querySelector('.chat-image-generation-placeholder') === placeholder; + tools[0].cancelFromStreaming({ type: ToolConfirmKind.Denied }, 'Image generation cancelled'); + counts.push(template.value.querySelectorAll('.chat-confirmation-widget-title').length); + tools[2].cancelFromStreaming({ type: ToolConfirmKind.Denied }, 'Image generation cancelled'); + counts.push(template.value.querySelectorAll('.chat-confirmation-widget-title').length); + await timeout(0); + assert.deepStrictEqual({ + counts, + samePlaceholder, + titles: [...template.value.querySelectorAll('.chat-confirmation-widget-title p')].map(title => title.textContent?.replace(/\u00a0/g, ' ')), + placeholders: template.value.querySelectorAll('.chat-image-generation-placeholder').length, + states: tools.map(tool => tool.state.get().type), + incorrectFailure: template.value.textContent?.includes('Generated image failed'), + progress: template.value.querySelector('.chat-working-progress')?.textContent?.trim(), + }, { + counts: [3, 3, 3, 3], + samePlaceholder: true, + titles: tools.map(() => 'Image generation cancelled'), + placeholders: 0, + states: tools.map(() => IChatToolInvocation.StateKind.Cancelled), + incorrectFailure: false, + progress: persistentProgress === ChatProgressAnimation.Off ? undefined : 'Working', + }); + request.response?.complete(); + renderer.renderElement(node, 0, template); + }); + } + } + + test('overlapping image generations preserve confirmation UI and ignore hidden attempts', () => { + const { model, request, renderer, template, node } = createPersistentProgressRenderer(); + const toolData = { id: 'image_generation', displayName: 'Generate Image', modelDescription: 'Generate Image', source: ToolDataSource.Internal }; + const confirmationMessages = { title: 'Generate Image?', message: new MarkdownString('Approve image generation') }; + const hidden = new ChatToolInvocation({ invocationMessage: 'Generating image', presentation: ToolInvocationPresentation.Hidden }, toolData, 'hidden-image', undefined, {}); + const first = new ChatToolInvocation({ invocationMessage: 'Generating image', confirmationMessages }, toolData, 'first-image', undefined, {}); + const second = ChatToolInvocation.createStreaming({ toolId: toolData.id, toolCallId: 'second-image', toolData }); + for (const tool of [hidden, first, second]) { + model.acceptResponseProgress(request, tool); + } + renderer.renderElement(node, 0, template); + const snapshot = () => ({ + placeholders: template.value.querySelectorAll('.chat-image-generation-placeholder').length, + dropdowns: template.value.querySelectorAll('.chat-tool-invocation-part:not(.has-confirmation) .chat-confirmation-widget-title').length, + confirmations: template.value.querySelectorAll('.has-confirmation').length, + }); + const observations = [snapshot()]; + const confirm = (tool: ChatToolInvocation, type: ToolConfirmKind.UserAction | ToolConfirmKind.Denied) => { + const state = tool.state.get(); + assert.strictEqual(state.type, IChatToolInvocation.StateKind.WaitingForConfirmation); + state.confirm({ type }); + }; + confirm(first, ToolConfirmKind.UserAction); + observations.push(snapshot()); + second.requestConfirmation({ confirmationMessages }); + observations.push(snapshot()); + confirm(second, ToolConfirmKind.UserAction); + observations.push(snapshot()); + first.requestConfirmation({ confirmationMessages }); + observations.push(snapshot()); + confirm(first, ToolConfirmKind.Denied); + observations.push(snapshot()); + second.requestConfirmation({ confirmationMessages }); + observations.push(snapshot()); + confirm(second, ToolConfirmKind.Denied); + observations.push(snapshot()); + assert.deepStrictEqual(observations, [ + { placeholders: 1, dropdowns: 1, confirmations: 1 }, + { placeholders: 1, dropdowns: 2, confirmations: 0 }, + { placeholders: 1, dropdowns: 1, confirmations: 1 }, + { placeholders: 1, dropdowns: 2, confirmations: 0 }, + { placeholders: 1, dropdowns: 1, confirmations: 1 }, + { placeholders: 1, dropdowns: 2, confirmations: 0 }, + { placeholders: 0, dropdowns: 1, confirmations: 1 }, + { placeholders: 0, dropdowns: 2, confirmations: 0 }, + ]); + request.response?.complete(); + renderer.renderElement(node, 0, template); + }); + + test('overlapping image generation progress is scoped to each response', () => { + const counts = []; + for (let index = 0; index < 2; index++) { + const { model, request, renderer, template, node } = createPersistentProgressRenderer(); + for (let call = 0; call < 2; call++) { + model.acceptResponseProgress(request, ChatToolInvocation.createStreaming({ + toolId: 'image_generation', + toolCallId: `image-${call}`, + toolData: { id: 'image_generation', displayName: 'Generate Image', modelDescription: 'Generate Image', source: ToolDataSource.Internal }, + })); + } + renderer.renderElement(node, 0, template); + counts.push(template.value.querySelectorAll('.chat-image-generation-placeholder').length); + } + assert.deepStrictEqual(counts, [1, 1]); + }); + + for (const width of [280, 760]) { + test(`image generation uses a compact glyph band in a ${width}px chat, animates while visible, and holds still with reduced motion or high contrast`, async () => { + const disposables = store.add(new DisposableStore()); + const container = dom.append(mainWindow.document.body, dom.$('div')); + disposables.add(toDisposable(() => container.remove())); + container.style.width = `${width}px`; + const instantiationService = workbenchInstantiationService(undefined, disposables); + let motionReduced = false; + const onDidChangeReducedMotion = disposables.add(new Emitter()); + instantiationService.stub(IAccessibilityService, new class extends TestAccessibilityService { + override onDidChangeReducedMotion = onDidChangeReducedMotion.event; + override isMotionReduced() { return motionReduced; } + }()); + const colors = { [foreground]: '#cccccc', [descriptionForeground]: '#9d9d9d', [focusBorder]: '#0078d4' }; + const themeService = new TestThemeService(new TestColorTheme(colors)); + instantiationService.stub(IThemeService, themeService); + const tool = new ChatToolInvocation( + { invocationMessage: 'Generating image' }, + { id: 'image_generation', displayName: 'Generate Image', modelDescription: 'Generate Image', source: ToolDataSource.Internal }, + 'image-placeholder', undefined, {}, + ); + const placeholder = disposables.add(instantiationService.createInstance(ChatImageGenerationProgressPart, tool)); + container.appendChild(placeholder.domNode); + const field = placeholder.domNode.querySelector('canvas.chat-image-loading-glyphs'); + assert.ok(field); + + const targetWindow = dom.getWindow(field); + const nextFrame = () => new Promise(resolve => dom.scheduleAtNextAnimationFrame(targetWindow, () => resolve())); + const frames = async (count: number) => { + for (let index = 0; index < count; index++) { + await nextFrame(); + } + }; + const changesWithin = async (count: number) => { + const before = field.toDataURL(); + for (let index = 0; index < count; index++) { + await nextFrame(); + if (field.toDataURL() !== before) { + return true; + } + } + return false; + }; + const paintedPixels = (x = 0, y = 0, regionWidth = field.width, regionHeight = field.height) => { + const pixels = field.getContext('2d')?.getImageData(x, y, regionWidth, regionHeight).data ?? []; + let count = 0; + for (let index = 3; index < pixels.length; index += 4) { + count += pixels[index] ? 1 : 0; + } + return count; + }; + + await frames(2); + const bounds = field.getBoundingClientRect(); + const painted = paintedPixels() > 0; + const animates = await changesWithin(120); + + container.style.display = 'none'; + await frames(2); + const pausesWhileHidden = !await changesWithin(10); + container.style.display = ''; + const resumesWhenShown = await changesWithin(120); + + motionReduced = true; + onDidChangeReducedMotion.fire(); + const stillFrame = field.toDataURL(); + const holdsStillWithReducedMotion = !await changesWithin(10); + + motionReduced = false; + themeService.setTheme(new TestColorTheme(colors, ColorScheme.HIGH_CONTRAST_DARK)); + const highContrastFrame = field.toDataURL(); + const holdsStillWithHighContrast = !await changesWithin(10); + + assert.deepStrictEqual({ + dimensions: [bounds.width, bounds.height], + devicePixels: field.width === Math.round(bounds.width * targetWindow.devicePixelRatio), + painted, + animates, + pausesWhileHidden, + resumesWhenShown, + holdsStillWithReducedMotion, + holdsStillWithHighContrast, + highContrastUsesSolidColors: highContrastFrame !== stillFrame, + semantics: [placeholder.domNode.getAttribute('role'), placeholder.domNode.getAttribute('aria-label'), placeholder.domNode.getAttribute('aria-busy'), field.parentElement?.getAttribute('aria-hidden')], + focusTargets: placeholder.domNode.querySelectorAll('button, a, [tabindex]').length, + }, { dimensions: [Math.min(width, 320), 50], devicePixels: true, painted: true, animates: true, pausesWhileHidden: true, resumesWhenShown: true, holdsStillWithReducedMotion: true, holdsStillWithHighContrast: true, highContrastUsesSolidColors: true, semantics: ['img', 'Generating image', 'true', 'true'], focusTargets: 0 }); + }); + } + + for (const toolId of ['image_generation', 'image_gen.imagegen']) { + test(`image generation for ${toolId} replaces its placeholder with a cancelled dropdown`, () => { + const { model, request, renderer, template, node } = createPersistentProgressRenderer(); + const tool = ChatToolInvocation.createStreaming({ + toolId, + toolCallId: 'cancelled-image', + chatRequestId: request.id, + toolData: { id: toolId, displayName: 'Generate Image', modelDescription: 'Generate Image', source: ToolDataSource.Internal }, + }); + model.acceptResponseProgress(request, tool); + renderer.renderElement(node, 0, template); + const before = template.value.querySelectorAll('.chat-image-generation-placeholder').length; + tool.cancelFromStreaming({ type: ToolConfirmKind.Denied }, 'Image generation cancelled'); + renderer.renderElement(node, 0, template); + assert.deepStrictEqual({ + before, + after: template.value.querySelectorAll('.chat-confirmation-widget-title').length, + placeholders: template.value.querySelectorAll('.chat-image-generation-placeholder').length, + completed: IChatToolInvocation.isComplete(tool), + progress: template.value.querySelector('.chat-working-progress')?.textContent?.trim(), + }, { before: 1, after: 1, placeholders: 0, completed: true, progress: 'Working' }); + request.response?.complete(); + renderer.renderElement(node, 0, template); + }); + } + for (const [pending, expected] of [ [{ kind: 'questionCarousel', questions: [{ id: 'q1', type: 'text', title: 'Choose a direction' }], allowSkip: true }, 'Waiting for your response'], [{ kind: 'planReview', title: 'Review plan', content: 'Check the renderer.', actions: [{ label: 'Implement' }], canProvideFeedback: false }, 'Plan review required'], @@ -3176,6 +4319,107 @@ suite('ChatListRenderer', () => { ]); }); + for (const [persistent, cancel] of [[false, false], [true, false], [true, true]] as const) { + test(`painting phrases rotate only in persistent progress and stop outside image generation (persistent=${persistent}, cancel=${cancel})`, () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { disposables, instantiationService, configurationService, request, response } = createPersistentProgressRenderer(); + const host = dom.$('div'); + setARIAContainer(host); + disposables.add(toDisposable(() => host.remove())); + configurationService.setUserConfiguration('accessibility.verboseChatProgressUpdates', true); + const working = { kind: 'working' as const, imageGeneration: true, isActive: true }; + const context = new class extends mock() { + override readonly element = response; + override readonly content = [working]; + override readonly contentIndex = 0; + override readonly suppressProgressShimmer = persistent; + }(); + const part = disposables.add(instantiationService.createInstance(ChatWorkingProgressContentPart, working, instantiationService.get(IMarkdownRendererService), context)); + const initial = part.workingLabel; + const logo = part.domNode.querySelector('.chat-working-logo-compact'); + await timeout(3999); + const beforeRotation = part.workingLabel; + part.updateWorkingContent(undefined, true, false, undefined, false, true); + await timeout(1); + const firstRotation = part.workingLabel; + await timeout(4000); + const secondRotation = part.workingLabel; + const restOfCycle: string[] = []; + for (let index = 0; index < 6; index++) { + await timeout(4000); + restOfCycle.push(part.workingLabel); + } + const announcements = [...host.querySelectorAll('.monaco-alert, .monaco-status')].map(node => node.textContent).filter(Boolean); + const sameLogo = logo === part.domNode.querySelector('.chat-working-logo-compact'); + part.updateWorkingContent(new MarkdownString('Authentication required'), true, true); + await timeout(8000); + const paused = part.workingLabel; + part.updateWorkingContent(undefined, true, false, undefined, false, true); + const restarted = part.workingLabel; + if (cancel) { + request.response?.cancel(); + } else { + request.response?.complete(); + } + await timeout(8000); + const afterCompletion = part.workingLabel; + const activeAfterCompletion = part.domNode.classList.contains('chat-working-progress-active'); + part.dispose(); + await timeout(8000); + assert.deepStrictEqual({ + initial, beforeRotation, firstRotation, secondRotation, restOfCycle, announcements, sameLogo, paused, restarted, afterCompletion, activeAfterCompletion, + }, { + initial: persistent ? 'Mixing the colors' : initial, + beforeRotation: initial, + firstRotation: persistent ? 'Sketching the scene' : initial, + secondRotation: persistent ? 'Adding a splash of color' : initial, + restOfCycle: persistent ? ['Bringing pixels to life', 'Painting the possibilities', 'Laying down the brushstrokes', 'Giving imagination a canvas', 'Turning ideas into pixels', 'Mixing the colors'] : Array.from({ length: 6 }, () => initial), + announcements: [initial], + sameLogo: true, + paused: 'Authentication required', + restarted: persistent ? 'Mixing the colors' : initial, + afterCompletion: persistent ? 'Mixing the colors' : initial, + activeAfterCompletion: false, + }); + })); + } + + test('image progress keeps rotating across response activity without inactivity warnings', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { disposables, instantiationService, configurationService, model, request, response } = createPersistentProgressRenderer(); + const host = dom.$('div'); + setARIAContainer(host); + disposables.add(toDisposable(() => host.remove())); + configurationService.setUserConfiguration('accessibility.verboseChatProgressUpdates', true); + const working = { kind: 'working' as const, imageGeneration: true, isActive: true, showDelayedProgressMessage: true }; + const context = new class extends mock() { + override readonly element = response; + override readonly content = [working]; + override readonly contentIndex = 0; + override readonly suppressProgressShimmer = true; + }(); + const part = disposables.add(instantiationService.createInstance(ChatWorkingProgressContentPart, working, instantiationService.get(IMarkdownRendererService), context)); + await timeout(3999); + model.acceptResponseProgress(request, { kind: 'markdownContent', content: new MarkdownString('Response activity') }); + await timeout(1); + const afterActivity = part.workingLabel; + await timeout(90_000); + const whileGenerating = part.workingLabel; + request.response?.complete(); + await timeout(90_000); + assert.deepStrictEqual({ + afterActivity, + whileGenerating, + afterCompletion: part.workingLabel, + announcements: [...host.querySelectorAll('.monaco-alert, .monaco-status')].map(node => node.textContent).filter(Boolean), + activeAfterCompletion: part.domNode.classList.contains('chat-working-progress-active'), + }, { + afterActivity: 'Sketching the scene', + whileGenerating: 'Turning ideas into pixels', + afterCompletion: 'Turning ideas into pixels', + announcements: ['Mixing the colors'], + activeAfterCompletion: false, + }); + })); + test('persistent working progress announces blocking-state labels but not rotating phrases', () => { const { disposables, instantiationService, configurationService, response } = createPersistentProgressRenderer(); const host = dom.$('div'); @@ -8127,16 +9371,431 @@ suite('ChatListRenderer', () => { disposables.dispose(); }); - test('generated image completion renders one gallery without duplicate hover previews or hidden tool placeholders', async () => { + for (const toolId of ['image_gen.imagegen', 'image_generation']) { + for (const thinkingStyle of Object.values(ThinkingDisplayMode)) { + for (const collapsedTools of Object.values(CollapsedToolsDisplayMode)) { + for (const [progress, progressVerbosity] of [ + [ChatProgressAnimation.Off, ChatProgressVerbosity.Verbose], + [ChatProgressAnimation.DrawMonochrome, ChatProgressVerbosity.Verbose], + [ChatProgressAnimation.Draw, ChatProgressVerbosity.Compact], + [ChatProgressAnimation.DrawMonochromeNoIcon, ChatProgressVerbosity.Compact], + ] as const) { + test(`image generation stays standalone: ${toolId}, ${thinkingStyle}, ${collapsedTools}, ${progress}, ${progressVerbosity}`, async () => { + const { disposables, configurationService, model, request, renderer, template, node } = createPersistentProgressRenderer({ thinkingStyle, collapsedTools, progressVerbosity, chatMode: ChatModeKind.Agent }); + configurationService.setUserConfiguration(ChatConfiguration.PersistentProgress, progress); + configurationService.setUserConfiguration(ChatConfiguration.CollapseCompletedResponses, true); + model.acceptResponseProgress(request, { kind: 'thinking', id: 'before-image', value: 'Planning an image' }); + const invocation = new ChatToolInvocation({ + invocationMessage: 'Generating image', + pastTenseMessage: 'Generated image', + }, { + id: toolId, + displayName: 'Generate Image', + modelDescription: 'Generate Image', + source: ToolDataSource.Internal, + }, 'image', undefined, {}, {}, request.id); + model.acceptResponseProgress(request, invocation); + renderer.renderElement(node, 0, template); + const standalone = () => template.renderedParts?.some(part => + part instanceof ChatToolInvocationPart && part.toolCallId === 'image' && !part.domNode.closest('.chat-thinking-box')) ?? false; + const runningStandalone = standalone(); + const runningPlaceholder = !!template.value.querySelector('.chat-image-generation-placeholder'); + const runningDetails = template.value.querySelectorAll('.chat-tool-invocation-part .chat-confirmation-widget-title').length; + const runningShimmer = !!template.value.querySelector('.chat-tool-invocation-part .shimmer-progress'); + + await invocation.didExecuteTool({ + content: [], + toolSpecificData: { kind: 'generatedImage' }, + toolResultDetails: { input: '{"prompt":"Draw a puppy"}', output: [{ type: 'embed', value: 'aW1hZ2U=', mimeType: 'image/png' }] }, + }); + renderer.renderElement(node, 0, template); + const completedToolStandalone = standalone(); + const collapsedPreviews = template.value.querySelectorAll('.chat-collapsible-top-level-resource-group').length; + model.acceptResponseProgress(request, { kind: 'thinking', id: 'after-image', value: 'Finishing the response' }); + model.acceptResponseProgress(request, { kind: 'markdownContent', content: new MarkdownString('Done.') }); + request.response?.complete(); + renderer.renderElement(node, 0, template); + const gallery = template.value.querySelector('.chat-generated-image-result'); + const disclosure = template.value.querySelector('.generated-image-tool-invocation .chat-confirmation-widget-title'); + const finalMessage = [...template.value.querySelectorAll('.rendered-markdown p')].find(paragraph => paragraph.textContent === 'Done.'); + + assert.deepStrictEqual({ + runningStandalone, + runningPlaceholder, + runningDetails, + runningShimmer, + completedToolStandalone, + collapsedPreviews, + finalStandalone: standalone(), + toolDisclosures: template.value.querySelectorAll('.generated-image-tool-invocation .chat-confirmation-widget-title').length, + headerToolIcons: template.value.querySelectorAll('.generated-image-tool-invocation > .chat-tool-call-icon').length, + toolIconGutters: template.value.querySelectorAll('.generated-image-tool-invocation.chat-tool-call-with-icon').length, + hasGallery: !!gallery, + galleryInsideSteps: !!gallery && !!template.completedResponseDisclosure?.contains(gallery), + galleryBeforeFinalMessage: !!gallery && !!finalMessage && !!(gallery.compareDocumentPosition(finalMessage) & Node.DOCUMENT_POSITION_FOLLOWING), + galleryBeforeDisclosure: !!gallery && !!disclosure && !!(gallery.compareDocumentPosition(disclosure) & Node.DOCUMENT_POSITION_FOLLOWING), + generatedCollapses: shouldCollapseCompletedResponsePart(invocation), + restoredCollapses: shouldCollapseCompletedResponsePart(invocation.toJSON()), + finalCollapsedPreviews: template.value.querySelectorAll('.chat-collapsible-top-level-resource-group').length, + finalPlaceholders: template.value.querySelectorAll('.chat-image-generation-placeholder').length, + }, { + runningStandalone: true, + runningPlaceholder: true, + runningDetails: 1, + runningShimmer: false, + completedToolStandalone: true, + collapsedPreviews: 0, + finalStandalone: true, + toolDisclosures: 1, + headerToolIcons: 1, + toolIconGutters: 1, + hasGallery: true, + galleryInsideSteps: false, + galleryBeforeFinalMessage: true, + galleryBeforeDisclosure: false, + generatedCollapses: false, + restoredCollapses: false, + finalCollapsedPreviews: 0, + finalPlaceholders: 0, + }); + disposables.dispose(); + }); + } + } + } + } + + suite('generated image preview loading', () => { + const imageData = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a79cAAAAASUVORK5CYII='; + + function createImageTool(toolId: string, requestId: string) { + return new ChatToolInvocation({ + invocationMessage: 'Generating image', + pastTenseMessage: 'Generated image', + }, { + id: toolId, + displayName: 'Generate Image', + modelDescription: 'Generate Image', + source: ToolDataSource.Internal, + }, 'image', undefined, {}, {}, requestId); + } + + for (const toolId of ['image_gen.imagegen', 'image_generation']) { + for (const restored of [false, true]) { + test(`${toolId} immediately renders an image instead of a file pill or warning (restored=${restored})`, async () => { + const { model, request, renderer, template, node } = createPersistentProgressRenderer(); + const tool = createImageTool(toolId, request.id); + await tool.didExecuteTool({ + content: [], + toolSpecificData: { kind: 'generatedImage' }, + toolResultDetails: { input: '{}', output: [{ type: 'embed', value: imageData, mimeType: 'image/png' }] }, + }); + model.acceptResponseProgress(request, restored ? tool.toJSON() : tool); + request.response?.complete(); + renderer.renderElement(node, 0, template); + + const gallery = template.value.querySelector('.chat-generated-image-result'); + const image = gallery?.querySelector('img.chat-attached-context-pill-image'); + assert.deepStrictEqual({ + images: gallery?.querySelectorAll('img.chat-attached-context-pill-image').length, + hasSource: !!image?.getAttribute('src'), + filePills: gallery?.querySelectorAll('.chat-attached-context-attachment:not(.image-attachment)').length, + warnings: gallery?.querySelectorAll('.codicon-warning, .codicon-warning-compact').length, + }, { + images: 1, + hasSource: true, + filePills: 0, + warnings: 0, + }); + await image!.decode(); + }); + } + + test(`${toolId} preserves its generated image through completion and subsequent requests`, async () => { + const { model, request, renderer, template, node } = createPersistentProgressRenderer(); + const tool = createImageTool(toolId, request.id); + model.acceptResponseProgress(request, tool); + renderer.renderElement(node, 0, template); + await tool.didExecuteTool({ + content: [], + toolSpecificData: { kind: 'generatedImage' }, + toolResultDetails: { input: '{}', output: [{ type: 'embed', value: imageData, mimeType: 'image/png' }] }, + }); + const gallery = template.value.querySelector('.chat-generated-image-result'); + assert.ok(gallery); + renderer.renderElement(node, 0, template); + const sameGalleryAfterToolCompletion = template.value.querySelector('.chat-generated-image-result') === gallery; + + await retry(async () => { + const image = template.value.querySelector('.chat-generated-image-result img'); + assert.ok(image?.complete && image.naturalWidth > 0); + }, 20, 50); + const image = template.value.querySelector('.chat-generated-image-result img')!; + const source = image.src; + request.response?.complete(); + renderer.renderElement(node, 0, template); + const sameImageAfterResponseCompletion = template.value.querySelector('.chat-generated-image-result img') === image; + + for (let index = 0; index < 2; index++) { + const nextRequest = model.addRequest({ + text: 'next', + parts: [new ChatRequestTextPart(new OffsetRange(0, 4), new Range(1, 1, 1, 5), 'next')], + }, { variables: [] }, 0); + renderer.disposeElement(node, 0, template); + renderer.renderElement(node, 0, template); + nextRequest.response?.complete(); + } + + assert.deepStrictEqual({ + sameGalleryAfterToolCompletion, + sameImageAfterResponseCompletion, + sameImageAfterNextRequests: template.value.querySelector('.chat-generated-image-result img') === image, + sameSource: image.src === source, + loaded: image.complete && image.naturalWidth > 0, + }, { + sameGalleryAfterToolCompletion: true, + sameImageAfterResponseCompletion: true, + sameImageAfterNextRequests: true, + sameSource: true, + loaded: true, + }); + }); + } + }); + + for (const failedImageFirst of [false, true]) { + test(`completed steps stop before the first ${failedImageFirst ? 'failed' : 'successful'} image generation without moving later content`, async () => { + const { configurationService, model, request, renderer, template, node } = createPersistentProgressRenderer({ chatMode: ChatModeKind.Agent }); + configurationService.setUserConfiguration(ChatConfiguration.CollapseCompletedResponses, true); + const createTool = (toolCallId: string, toolId: string) => new ChatToolInvocation({ + invocationMessage: toolCallId, + pastTenseMessage: toolCallId, + }, { + id: toolId, + displayName: toolId, + modelDescription: toolId, + source: ToolDataSource.Internal, + }, toolCallId, undefined, {}, {}, request.id); + const beforeTools = [createTool('before-one', 'read_file'), createTool('before-two', 'read_file')]; + for (const tool of beforeTools) { + await tool.didExecuteTool({ content: [] }); + model.acceptResponseProgress(request, tool); + } + const imageTools: ChatToolInvocation[] = []; + if (failedImageFirst) { + const failedImage = createTool('failed-image', 'image_generation'); + await failedImage.didExecuteTool({ content: [], toolResultError: 'Image generation returned no usable image.' }); + model.acceptResponseProgress(request, failedImage); + imageTools.push(failedImage); + } + const image = createTool('image', 'image_generation'); + model.acceptResponseProgress(request, image); + await image.didExecuteTool({ + content: [], + toolSpecificData: { kind: 'generatedImage' }, + toolResultDetails: { input: '{}', output: [{ type: 'embed', value: 'aW1hZ2U=', mimeType: 'image/png' }] }, + }); + imageTools.push(image); + const afterTool = createTool('after-image', 'read_file'); + await afterTool.didExecuteTool({ content: [] }); + model.acceptResponseProgress(request, afterTool); + const finalMessage = { kind: 'markdownContent', content: new MarkdownString('Done.') } as const; + model.acceptResponseProgress(request, finalMessage); + renderer.renderElement(node, 0, template); + const toolLabel = (node: Element) => node.classList.contains('generated-image-tool-invocation') ? 'image' : node.querySelector('p')?.textContent?.trim(); + const toolLabels = (container: HTMLElement) => [...container.querySelectorAll('.chat-tool-invocation-part')].map(toolLabel); + const orderWhileResponding = toolLabels(template.value); + + request.response?.complete(); + renderer.renderElement(node, 0, template); + const disclosure = template.completedResponseDisclosure; + const firstImage = [...template.value.querySelectorAll('.chat-tool-invocation-part')] + .find(node => toolLabel(node) === imageTools[0].toolCallId); + const restoredContent = [...beforeTools, ...imageTools, afterTool].map(tool => tool.toJSON()); + const expectedOrder = [...beforeTools, ...imageTools, afterTool].map(tool => tool.toolCallId); + assert.deepStrictEqual({ + orderWhileResponding, + orderAfterCompletion: toolLabels(template.value), + collapsedTools: disclosure ? toolLabels(disclosure) : [], + disclosureEndsBeforeImage: !!disclosure && !!firstImage && disclosure.nextSibling === firstImage, + restoredCollapseEnd: getCompletedResponseCollapseEndIndex([...restoredContent, finalMessage], restoredContent.length), + }, { + orderWhileResponding: expectedOrder, + orderAfterCompletion: expectedOrder, + collapsedTools: ['before-one', 'before-two'], + disclosureEndsBeforeImage: true, + restoredCollapseEnd: 2, + }); + }); + } + + for (const { name, width, imageWidth, imageHeight, imageCount, animate } of [ + { name: 'portrait', width: 760, imageWidth: 800, imageHeight: 1600, imageCount: 1 }, + { name: 'landscape', width: 760, imageWidth: 1600, imageHeight: 800, imageCount: 1 }, + { name: 'narrow', width: 320, imageWidth: 1600, imageHeight: 800, imageCount: 1 }, + { name: 'gallery', width: 760, imageWidth: 800, imageHeight: 1600, imageCount: 2 }, + { name: 'portrait reveal', width: 760, imageWidth: 800, imageHeight: 1600, imageCount: 1, animate: true }, + { name: 'landscape reveal', width: 760, imageWidth: 1600, imageHeight: 800, imageCount: 1, animate: true }, + { name: 'narrow reveal', width: 320, imageWidth: 1600, imageHeight: 800, imageCount: 1, animate: true }, + { name: '3:2 landscape reveal', width: 760, imageWidth: 1536, imageHeight: 1024, imageCount: 1, animate: true }, + { name: 'fractional-width reveal', width: 320.5, imageWidth: 1536, imageHeight: 1024, imageCount: 1, animate: true }, + ]) { + test(`generated image ${name} is height-capped and keeps its download action beside it`, async () => { + const { disposables, instantiationService, configurationService, container, model, request, renderer, template, node } = createPersistentProgressRenderer(); + configurePersistentProgressTypography(container, 13); + container.style.width = `${width}px`; + configurationService.setUserConfiguration(ChatConfiguration.PersistentProgress, ChatProgressAnimation.Off); + if (animate) { + instantiationService.stub(IAccessibilityService, new class extends TestAccessibilityService { + override isMotionReduced(): boolean { return false; } + }()); + } + const saveAction = instantiationService.createInstance(MenuItemAction, { + id: 'workbench.action.chat.saveResources', + title: 'Save...', + icon: Codicon.cloudDownload, + }, undefined, undefined, undefined, undefined); + instantiationService.stub(IMenuService, new class extends TestMenuService { + override createMenu(id: MenuId) { + return { + onDidChange: Event.None, + dispose: () => { }, + getActions: (): [string, MenuItemAction[]][] => id === MenuId.ChatToolOutputResourceToolbar ? [['navigation', [saveAction]]] : [], + }; + } + }()); + const canvas = dom.$('canvas', { width: imageWidth, height: imageHeight }); + const imageData = canvas.toDataURL('image/png').split(',')[1]; + const tool = new ChatToolInvocation({ + invocationMessage: 'Generating image', + }, { + id: 'image_generation', + displayName: 'Generate Image', + modelDescription: 'Generate Image', + source: ToolDataSource.Internal, + }, 'image-layout', undefined, {}, {}, request.id); + if (animate) { + model.acceptResponseProgress(request, tool); + renderer.renderElement(node, 0, template); + } + await tool.didExecuteTool({ + content: [], + toolSpecificData: { kind: 'generatedImage' }, + toolResultDetails: { + input: '{}', + output: Array.from({ length: imageCount }, () => ({ type: 'embed' as const, value: imageData, mimeType: 'image/png' })), + }, + }); + if (!animate) { + model.acceptResponseProgress(request, tool); + } + request.response?.complete(); + renderer.renderElement(node, 0, template); + + const gallery = template.value.querySelector('.chat-generated-image-result'); + const saveButton = gallery?.querySelector('.chat-collapsible-io-resource-actions [role="button"]'); + assert.ok(gallery && saveButton); + const group = saveButton.closest('.chat-collapsible-io-resource-group')!; + const imageReveal = gallery.querySelector('.chat-image-reveal'); + const revealState = () => ({ + direction: mainWindow.getComputedStyle(group).flexDirection, + actionVisibility: mainWindow.getComputedStyle(saveButton).visibility, + actionOnRight: saveButton.getBoundingClientRect().left >= imageReveal!.getBoundingClientRect().right, + extraHeight: group.getBoundingClientRect().height - imageReveal!.getBoundingClientRect().height, + }); + const revealStates = animate ? [revealState()] : []; + await retry(async () => { + const images = [...template.value.querySelectorAll('.chat-generated-image-result img')]; + assert.ok(images.length === imageCount && images.every(image => image.complete && image.naturalWidth > 0 + && image.closest('.image-attachment')?.getAttribute('aria-busy') === 'false')); + }, 20, 50); + if (animate) { + assert.ok(imageReveal); + assert.ok(imageReveal.classList.contains('revealing')); + const field = imageReveal.querySelector('.chat-image-loading-glyphs'); + const clock = field?.getAnimations()[0]; + assert.ok(clock); + const animations = gallery.getAnimations({ subtree: true }); + for (const fraction of [0, 0.5, 0.9, 1]) { + for (const animation of animations) { + animation.pause(); + animation.currentTime = Number(animation.effect?.getTiming().duration) * fraction; + } + await new Promise(resolve => disposables.add(dom.scheduleAtNextAnimationFrame(mainWindow, () => resolve()))); + revealStates.push(revealState()); + } + const bounds = () => { + const frame = imageReveal.getBoundingClientRect(); + const image = gallery.querySelector('img')!.getBoundingClientRect(); + const action = saveButton.getBoundingClientRect(); + return { + layout: [frame.left, frame.top, frame.width, frame.height, group.getBoundingClientRect().height, template.rowContainer.getBoundingClientRect().height], + imageSize: [image.width, image.height], + actionPosition: [action.left, action.top], + }; + }; + const beforeFinish = bounds(); + await retry(async () => assert.ok(!imageReveal.classList.contains('revealing')), 100, 50); + const afterFinish = bounds(); + assert.deepStrictEqual({ + revealStates, + finalBounds: afterFinish, + actionVisibility: mainWindow.getComputedStyle(saveButton).visibility, + }, { + revealStates: Array.from({ length: 5 }, () => ({ direction: 'row', actionVisibility: 'hidden', actionOnRight: true, extraHeight: 0 })), + finalBounds: beforeFinish, + actionVisibility: 'visible', + }); + } + const imageRects = [...gallery.querySelectorAll('img')].map(image => image.getBoundingClientRect()); + const galleryRect = gallery.getBoundingClientRect(); + const buttonRect = saveButton.getBoundingClientRect(); + const gap = buttonRect.left - Math.max(...imageRects.map(rect => rect.right)); + const leftInset = Math.min(...imageRects.map(rect => rect.left)) - galleryRect.left; + const alignmentOffset = Math.abs(buttonRect.top + buttonRect.height / 2 - (imageRects[0].top + imageRects[0].height / 2)); + saveButton.focus(); + + assert.deepStrictEqual({ + heightCapped: imageRects.every(rect => rect.height > 0 && rect.height <= Math.min(400, mainWindow.innerHeight * 0.6) + 1), + aspectRatioPreserved: imageRects.every(rect => Math.abs(rect.width / rect.height - imageWidth / imageHeight) < 0.01), + leftAligned: leftInset >= 0 && leftInset <= 2, + adjacent: gap >= 8 && gap <= 10, + actionAligned: alignmentOffset <= 1, + noOverflow: buttonRect.right <= galleryRect.right + 1, + keyboardAccessible: mainWindow.document.activeElement === saveButton, + }, { + heightCapped: true, + aspectRatioPreserved: true, + leftAligned: true, + adjacent: true, + actionAligned: true, + noOverflow: true, + keyboardAccessible: true, + }); + disposables.dispose(); + }); + } + + test('generated image completion renders embedded and referenced images without duplicate previews or hidden placeholders', async () => { const disposables = store.add(new DisposableStore()); const instantiationService = workbenchInstantiationService(undefined, disposables); - const generatedImageHoverContents: HTMLElement[] = []; + const imageData = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a79cAAAAASUVORK5CYII='; + const imageUri = toAgentHostContentUri(URI.parse('generated-images:/session/generated-image.png?version=1'), 'remote'); + const fileService = disposables.add(new class extends TestFileService { + override async readFile(resource: URI) { + return { ...await super.readFile(resource), value: decodeBase64(imageData) }; + } + }()); + instantiationService.stub(IFileService, fileService); + const generatedImageHoverContents = new Set(); instantiationService.stub(IHoverService, { ...NullHoverService, setupDelayedHover: (target, hoverOptions) => { const options = typeof hoverOptions === 'function' ? hoverOptions() : hoverOptions; - if (target.closest('.chat-generated-image-result') && dom.isHTMLElement(options.content)) { - generatedImageHoverContents.push(options.content); + if (target.classList.contains('image-attachment') && dom.isHTMLElement(options.content)) { + const content = options.content; + generatedImageHoverContents.add(content); + return toDisposable(() => generatedImageHoverContents.delete(content)); } return Disposable.None; }, @@ -8215,6 +9874,7 @@ suite('ChatListRenderer', () => { model.acceptResponseProgress(request, { kind: 'thinking', value: 'Planning image variations', id: 'thinking-2' }); renderer.renderElement(node, 0, template); + const progressiveGalleries: { galleries: number; images: number; responseComplete: boolean }[] = []; for (const [index, imageTool] of imageTools.entries()) { model.acceptResponseProgress(request, imageTool); renderer.renderElement(node, 0, template); @@ -8223,10 +9883,21 @@ suite('ChatListRenderer', () => { toolSpecificData: { kind: 'generatedImage' }, toolResultDetails: { input: '{"prompt":"Draw a fox"}', - output: [{ type: 'embed', value: `aW1hZ2U${index}`, mimeType: 'image/png' }], + output: index === 0 + ? [{ type: 'embed', value: imageData, mimeType: 'image/png' }] + : [{ type: 'ref', uri: imageUri, mimeType: 'image/png' }], }, }); renderer.renderElement(node, 0, template); + await retry(async () => { + const images = [...template.value.querySelectorAll('.chat-generated-image-result img')]; + assert.ok(images.length === index + 1 && images.every(image => image.complete && image.naturalWidth > 0)); + }, 20, 50); + progressiveGalleries.push({ + galleries: template.value.querySelectorAll('.chat-generated-image-result').length, + images: template.value.querySelectorAll('.chat-generated-image-result .chat-attached-context-pill-image').length, + responseComplete: response.isComplete, + }); if (index === 0) { model.acceptResponseProgress(request, { kind: 'thinking', value: 'Planning the second variation', id: 'thinking-3' }); renderer.renderElement(node, 0, template); @@ -8246,20 +9917,31 @@ suite('ChatListRenderer', () => { await timeout(150); assert.deepStrictEqual({ + progressiveGalleries, resourceGroups: template.value.querySelectorAll('.chat-collapsible-io-resource-group').length, largeOutcomes: template.value.querySelectorAll('.chat-generated-image-result').length, multipleImageOutcomes: template.value.querySelectorAll('.chat-generated-image-result.multiple').length, generatedImageInvocations: template.value.querySelectorAll('.generated-image-tool-invocation').length, - generatedImageHovers: generatedImageHoverContents.length, - generatedImageHoverPreviews: generatedImageHoverContents.reduce((count, content) => count + content.querySelectorAll('.chat-attached-context-image').length, 0), + inputOutputDisclosures: template.value.querySelectorAll('.generated-image-tool-invocation .chat-confirmation-widget-title').length, + hiddenGalleryOwners: [...template.value.querySelectorAll('.generated-image-tool-invocation')].filter(part => part.style.display === 'none').length, + generatedImageHovers: generatedImageHoverContents.size, + generatedImageHoverPreviews: [...generatedImageHoverContents].reduce((count, content) => count + content.querySelectorAll('.chat-attached-context-image').length, 0), + imageResourcesRead: [...new Set(fileService.readOperations.map(operation => operation.resource.toString()))], emptyToolPlaceholders: [...template.value.querySelectorAll('.chat-tool-invocation-part')].filter(part => !part.hasChildNodes()).length, }, { + progressiveGalleries: [ + { galleries: 1, images: 1, responseComplete: false }, + { galleries: 1, images: 2, responseComplete: false }, + ], resourceGroups: 1, largeOutcomes: 1, multipleImageOutcomes: 1, - generatedImageInvocations: 1, + generatedImageInvocations: 2, + inputOutputDisclosures: 2, + hiddenGalleryOwners: 0, generatedImageHovers: 2, generatedImageHoverPreviews: 0, + imageResourcesRead: [imageUri.toString()], emptyToolPlaceholders: 0, }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatListWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatListWidget.test.ts index 984264aa3a8fe8..832f7d58ba1cf6 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatListWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatListWidget.test.ts @@ -6,11 +6,13 @@ import assert from 'assert'; import { IContextMenuDelegate } from '../../../../../../base/browser/contextmenu.js'; import { mainWindow } from '../../../../../../base/browser/window.js'; -import { retry, timeout } from '../../../../../../base/common/async.js'; +import { DeferredPromise, retry, timeout } from '../../../../../../base/common/async.js'; +import { decodeBase64 } from '../../../../../../base/common/buffer.js'; import { Event } from '../../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { DisposableStore, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { constObservable } from '../../../../../../base/common/observable.js'; +import { isEqual } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { upcastPartial } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; @@ -26,7 +28,7 @@ import { WorkbenchListSupportsFind } from '../../../../../../platform/list/brows import { scrollbarShadow } from '../../../../../../platform/theme/common/colorRegistry.js'; import { IEditorResolverService, RegisteredEditorPriority } from '../../../../../services/editor/common/editorResolverService.js'; import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; -import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; +import { TestFileService, workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; import { IChatAccessibilityService, isChatContextMenuActionContext } from '../../../browser/chat.js'; import { ChatAttachmentWidgetRegistry, IChatAttachmentWidgetRegistry } from '../../../browser/attachments/chatAttachmentWidgetRegistry.js'; import { computeScrollDownState, getAnchoredScrollTop, AutoScrollHolds, UserToggleResizeState, ChatListWidget, IChatListWidgetOptions, getChatContextMenuTargetContext, isChatBackgroundContextMenuTarget, shouldShowChatLinkOpenWith } from '../../../browser/widget/chatListWidget.js'; @@ -276,9 +278,195 @@ suite('ChatListWidget', () => { })); widget.setViewModel(viewModel); widget.setVisible(true); - return { disposables, model, viewModel, container, widget, contextKeyService: instantiationService.get(IContextKeyService) }; + return { disposables, instantiationService, model, viewModel, container, widget, contextKeyService: instantiationService.get(IContextKeyService) }; } + suite('generated image preview retention', () => { + for (const width of [280, 500]) { + test(`virtualized images retain their row and scroll height while reloading (${width}px)`, async () => { + const { disposables, instantiationService, model, viewModel, container, widget } = createWidget({}, undefined, true); + container.style.width = `${width}px`; + container.classList.add('interactive-list'); + const canvas = mainWindow.document.createElement('canvas'); + canvas.width = 800; + canvas.height = 1600; + const imageData = decodeBase64(canvas.toDataURL('image/png').split(',')[1]); + const imageResource = URI.parse('generated-images:/virtualized/image.png'); + const reloadedImage = new DeferredPromise(); + let delayReads = false; + disposables.add(toDisposable(() => { + if (!reloadedImage.isSettled) { + void reloadedImage.complete(imageData); + } + })); + instantiationService.stub(IFileService, disposables.add(new class extends TestFileService { + override async readFile(resource: URI) { + const file = await super.readFile(resource); + return isEqual(resource, imageResource) + ? { ...file, value: delayReads ? await reloadedImage.p : imageData } + : file; + } + }())); + const addRequest = (text: string) => model.addRequest({ + text, + parts: [new ChatRequestTextPart(new OffsetRange(0, text.length), new Range(1, 1, 1, text.length + 1), text)], + }, { variables: [] }, 0); + const request = addRequest('Generate a portrait image.'); + const tool = new ChatToolInvocation({ + invocationMessage: 'Generating image', + pastTenseMessage: 'Generated image', + }, { + id: 'image_generation', + displayName: 'Generate Image', + modelDescription: 'Generate Image', + source: ToolDataSource.Internal, + }, 'virtualized-image', undefined, {}, {}, request.id); + await tool.didExecuteTool({ + content: [], + toolSpecificData: { kind: 'generatedImage' }, + toolResultDetails: { input: '{}', output: [{ type: 'ref', uri: imageResource, mimeType: 'image/png' }] }, + }); + model.acceptResponseProgress(request, tool); + request.response?.complete(); + widget.refresh(); + widget.layout(300, width); + const selector = '.chat-generated-image-result img'; + const firstImage = container.querySelector(selector); + assert.ok(firstImage); + await retry(async () => assert.ok(firstImage.complete && firstImage.naturalWidth > 0), 20, 50); + await waitForStableLayout(widget); + const response = viewModel.getItems().filter(isResponseVM)[0]; + const measuredHeight = response.currentRenderedHeight; + const loadedHeight = firstImage.getBoundingClientRect().height; + for (let index = 0; index < 8; index++) { + const followup = addRequest(`Follow-up ${index}`); + model.acceptResponseProgress(followup, { kind: 'markdownContent', content: new MarkdownString(`Response ${index}.\n\n`.repeat(20)) }); + followup.response?.complete(); + } + widget.refresh(); + widget.scrollToEnd(); + await waitForStableLayout(widget); + const wasVirtualized = !firstImage.isConnected; + const scrollHeight = widget.scrollHeight; + const scrollHeights: number[] = []; + disposables.add(widget.onDidChangeContentHeight(() => scrollHeights.push(widget.scrollHeight))); + delayReads = true; + widget.reveal(response, 0); + const readerScrollTop = widget.scrollTop; + const nextImage = container.querySelector(selector); + assert.ok(nextImage); + const pendingHeight = nextImage.getBoundingClientRect().height; + await waitForStableLayout(widget); + const beforeLoad = { rowHeight: response.currentRenderedHeight, scrollTop: widget.scrollTop, busy: nextImage.closest('.image-attachment')?.getAttribute('aria-busy') }; + await reloadedImage.complete(imageData); + await retry(async () => assert.ok(nextImage.complete && nextImage.naturalWidth > 0), 20, 50); + await waitForStableLayout(widget); + + assert.deepStrictEqual({ + wasVirtualized, + remounted: nextImage !== firstImage, + reservedImageHeight: Math.abs(pendingHeight - loadedHeight) <= 1, + beforeLoad, + finalRowHeight: response.currentRenderedHeight, + finalScrollTop: widget.scrollTop, + maxScrollHeightChange: Math.max(0, ...scrollHeights.map(height => Math.abs(height - scrollHeight))), + }, { + wasVirtualized: true, + remounted: true, + reservedImageHeight: true, + beforeLoad: { rowHeight: measuredHeight, scrollTop: readerScrollTop, busy: 'true' }, + finalRowHeight: measuredHeight, + finalScrollTop: readerScrollTop, + maxScrollHeightChange: 0, + }); + }); + } + + for (const toolId of ['image_generation', 'image_gen.imagegen']) { + for (const height of [300, 650]) { + test(`${toolId} preserves a loaded image while a follow-up starts and streams (height=${height})`, async () => { + const { model, container, widget } = createWidget({}, configuration => { + configuration.setUserConfiguration(ChatConfiguration.PersistentProgress, ChatProgressAnimation.Draw); + }, true); + container.style.height = `${height}px`; + container.classList.add('interactive-list'); + const text = 'Generate a portrait image.'; + const request = model.addRequest({ + text, + parts: [new ChatRequestTextPart(new OffsetRange(0, text.length), new Range(1, 1, 1, text.length + 1), text)], + }, { variables: [] }, 0); + model.acceptResponseProgress(request, { kind: 'thinking', value: 'Evaluating image generation skills', id: 'thinking' }); + const tool = new ChatToolInvocation({ + invocationMessage: 'Generating image', + pastTenseMessage: 'Generated image', + }, { + id: toolId, + displayName: 'Generate Image', + modelDescription: 'Generate Image', + source: ToolDataSource.Internal, + }, 'image', undefined, {}, {}, request.id); + const canvas = mainWindow.document.createElement('canvas'); + canvas.width = 800; + canvas.height = 1200; + const imageData = canvas.toDataURL('image/png').split(',')[1]; + model.acceptResponseProgress(request, tool); + await tool.didExecuteTool({ + content: [], + toolSpecificData: { kind: 'generatedImage' }, + toolResultDetails: { input: '{}', output: [{ type: 'embed', value: imageData, mimeType: 'image/png' }] }, + }); + model.acceptResponseProgress(request, { kind: 'markdownContent', content: new MarkdownString('Task completed: Generated the requested image.') }); + request.response?.complete(); + widget.refresh(); + widget.layout(height, 500); + const imageSelector = '.chat-generated-image-result img'; + const image = container.querySelector(imageSelector); + assert.ok(image); + await image.decode(); + await waitForStableLayout(widget); + widget.scrollToEnd(); + await waitForStableLayout(widget); + const source = image.src; + const capture = () => ({ + sameImage: container.querySelector(imageSelector) === image, + connected: image.isConnected, + sameSource: image.src === source, + loaded: image.complete && image.naturalWidth === 800, + busy: image.closest('.image-attachment')?.getAttribute('aria-busy'), + }); + const states = [capture()]; + + const followupText = 'Thanks'; + const followup = model.addRequest({ + text: followupText, + parts: [new ChatRequestTextPart(new OffsetRange(0, followupText.length), new Range(1, 1, 1, followupText.length + 1), followupText)], + }, { variables: [] }, 0); + widget.refresh(); + widget.scrollToEnd(); + states.push(capture()); + await waitForStableLayout(widget); + states.push(capture()); + model.acceptResponseProgress(followup, { kind: 'markdownContent', content: new MarkdownString('You are welcome.') }); + widget.refresh(); + await waitForStableLayout(widget); + states.push(capture()); + followup.response?.complete(); + widget.refresh(); + await waitForStableLayout(widget); + states.push(capture()); + + assert.deepStrictEqual(states, Array.from({ length: 5 }, () => ({ + sameImage: true, + connected: true, + sameSource: true, + loaded: true, + busy: 'false', + }))); + }); + } + } + }); + test('updates a request summary after rendering without changing the request or resetting user expansion', async () => { const { model, container, widget } = createWidget(); const text = 'Call #readAttemptComparison and review the complete implementation instructions.'; diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerActionItem.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerActionItem.test.ts index 52d18d5e7c233a..1a9de51e833071 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerActionItem.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerActionItem.test.ts @@ -236,13 +236,20 @@ suite('ModelPickerActionItem', () => { const widgetElement = $('button'); const anchors: (HTMLElement | undefined)[] = []; const contextViewLayers: (number | undefined)[] = []; + const selections: string[] = []; + const models: ILanguageModelChatMetadataAndIdentifier[] = [ + { identifier: 'copilot:gpt', metadata: upcastPartial({ id: 'gpt', isUserSelectable: true }) }, + { identifier: 'openai:gpt', metadata: upcastPartial({ id: 'gpt', isUserSelectable: false }) }, + ]; + let enabled = true; let disposed = 0; const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stubInstance(ModelPickerWidget, { onDidChangeSelection: Event.None, onDidChangeMinimumWidth: Event.None, domNode: widgetElement, - nameButton: undefined, + nameButton: widgetElement, + canOpenWithFilter: () => enabled, minimumWidth: 60, setSelectedModel: () => { }, setCompact: () => { }, @@ -255,9 +262,9 @@ suite('ModelPickerActionItem', () => { const action: IAction = { id: 'test.modelPicker', label: '', tooltip: '', class: undefined, enabled: true, run: async () => { } }; const delegate: IModelPickerDelegate = { currentModel: constObservable(undefined), - setModel: () => { }, + setModel: model => selections.push(model.identifier), setModelProgrammatically: () => { }, - getModels: () => [], + getModels: () => models, getPresentationOptions: () => ({ useGroupedModelPicker: true, showManageModelsAction: false, @@ -282,6 +289,14 @@ suite('ModelPickerActionItem', () => { item.render(second); item.openModelPicker(); item.show(second); + assert.deepStrictEqual(selections, [], 'opening the picker does not select a model'); + const control = item.getModelPickerControl()!; + const accepted = [control.select('copilot:gpt'), control.select('gpt'), control.select('openai:gpt')]; + enabled = false; + accepted.push(control.select('copilot:gpt')); + assert.deepStrictEqual({ accepted, selections, disabledControl: item.getModelPickerControl() }, { + accepted: [true, false, false, false], selections: ['copilot:gpt'], disabledControl: undefined, + }); const rendered = { first: first.childElementCount, second: second.contains(widgetElement) }; item.dispose(); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerTabbedWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerTabbedWidget.test.ts index 6db914e3e5487e..7ab56072827c51 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerTabbedWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerTabbedWidget.test.ts @@ -422,6 +422,17 @@ suite('TabbedModelPicker', () => { }); } + test('idempotent filtered opening focuses the exact row without selecting it', () => { + const a = model('same'); + const b = { ...model('same'), identifier: 'codex/target', metadata: { ...a.metadata, vendor: 'codex', modelPickerGroup: { id: 'copilot', label: 'GitHub Copilot' } } }; + const { picker, popup, selections } = createPicker({ models: [a, b] }); + picker.openWithFilter({ initialFilterValue: 'same', initialFocusItemId: b.identifier }); + picker.openWithFilter({ initialFilterValue: 'same', initialFocusItemId: b.identifier }); + const input = popup.querySelector('input'); + assert.deepStrictEqual({ visible: picker.isVisible, query: input?.value, selections }, { visible: true, query: 'same', selections: [] }); + assert.ok(popup.querySelector('.monaco-list-row.focused')); + }); + for (const initiallyAuto of [false, true]) { test(`mode and provider changes retain the popup size when opened in ${initiallyAuto ? 'Auto' : 'manual'} mode`, () => { const older = model('example-5.5'); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerTelemetry.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerTelemetry.test.ts index c64ee1a2ebc2b5..7688fcb0bb1f97 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerTelemetry.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerTelemetry.test.ts @@ -5,6 +5,7 @@ import assert from 'assert'; import * as dom from '../../../../../../../../base/browser/dom.js'; +import { EventType as TouchEventType } from '../../../../../../../../base/browser/touch.js'; import { mainWindow } from '../../../../../../../../base/browser/window.js'; import { DeferredPromise, timeout } from '../../../../../../../../base/common/async.js'; import { IStringDictionary } from '../../../../../../../../base/common/collections.js'; @@ -543,6 +544,22 @@ suite('ModelPickerTelemetry', () => { }); } + for (const target of ['name', 'config']) { + test(`a touch tap on the model ${target} opens the picker`, () => { + const result = createPicker(false); + result.picker.render(result.container); + result.hide(); + const trigger = result.container.querySelector(`.model-picker-${target}`)!; + trigger.dispatchEvent(new CustomEvent(TouchEventType.Tap, { bubbles: true, cancelable: true })); + const opened = { + wholeChipActive: result.picker.domNode!.classList.contains('model-picker-active'), + expanded: trigger.getAttribute('aria-expanded'), + }; + result.hide(); + assert.deepStrictEqual(opened, { wholeChipActive: true, expanded: 'true' }); + }); + } + test('a disabled input readout cannot open model details', () => { const result = createPicker(true); result.picker.render(result.container); diff --git a/src/vs/workbench/contrib/chat/test/common/chatEntitlementService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatEntitlementService.test.ts index 4d7035bbe25685..1211a104e10d16 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatEntitlementService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatEntitlementService.test.ts @@ -587,6 +587,52 @@ suite('ChatEntitlementService', () => { }); }); + const availabilityChanges: { name: string; before: ChatEntitlementService['quotas']; after: ChatEntitlementService['quotas'] }[] = [ + { + name: 'additional usage permission', + before: { premiumChat: { percentRemaining: 0, unlimited: false }, additionalUsageEnabled: true }, + after: { premiumChat: { percentRemaining: 0, unlimited: false }, additionalUsageEnabled: false }, + }, + { + name: 'pooled allowance availability', + before: { premiumChat: { percentRemaining: 100, unlimited: true, hasQuota: true } }, + after: { premiumChat: { percentRemaining: 100, unlimited: true, hasQuota: false } }, + }, + { + name: 'premium allowance becomes limited', + before: { premiumChat: { percentRemaining: 0, unlimited: true } }, + after: { premiumChat: { percentRemaining: 0, unlimited: false } }, + }, + { + name: 'session rate limit becomes limited', + before: { sessionRateLimit: { percentRemaining: 0, unlimited: true } }, + after: { sessionRateLimit: { percentRemaining: 0, unlimited: false } }, + }, + { + name: 'weekly rate limit becomes limited', + before: { weeklyRateLimit: { percentRemaining: 0, unlimited: true } }, + after: { weeklyRateLimit: { percentRemaining: 0, unlimited: false } }, + }, + ]; + for (const { name, before, after } of availabilityChanges) { + test(`signals quota availability changes without a percentage change: ${name}`, () => { + const service = createService(); + service.acceptQuotas(before); + let changes = 0; + store.add(service.onDidChangeQuotaRemaining(() => changes++)); + + service.acceptQuotas(after); + const afterChange = changes; + service.acceptQuotas(after); + const afterIdenticalSnapshot = changes; + service.acceptQuotas(before); + + assert.deepStrictEqual({ afterChange, afterIdenticalSnapshot, afterRecovery: changes }, { + afterChange: 1, afterIdenticalSnapshot: 1, afterRecovery: 2, + }); + }); + } + test('merges defined snapshot fields until the snapshot is removed', () => { const service = createService(); service.acceptQuotas({ diff --git a/src/vs/workbench/contrib/chat/test/common/model/chatToolInvocation.test.ts b/src/vs/workbench/contrib/chat/test/common/model/chatToolInvocation.test.ts index 13dee4dc0df2e7..268682ae15d2b5 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/chatToolInvocation.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/chatToolInvocation.test.ts @@ -8,7 +8,7 @@ import { CancellationTokenSource } from '../../../../../../base/common/cancellat import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { ConfirmedReason, IChatToolInvocation, ToolConfirmKind, ToolDeniedReason } from '../../../common/chatService/chatService.js'; import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; -import { IToolData, ToolDataSource } from '../../../common/tools/languageModelToolsService.js'; +import { IToolData, IToolResultInputOutputDetails, ToolDataSource } from '../../../common/tools/languageModelToolsService.js'; suite('ChatToolInvocation permission provenance', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -70,6 +70,33 @@ suite('ChatToolInvocation permission provenance', () => { assert.deepStrictEqual(results, reasons); }); + test('provider cancellation retains details and cannot be overwritten by a late completion', async () => { + const invocation = new ChatToolInvocation({ invocationMessage: 'Run tool' }, tool, 'running', undefined, { prompt: 'Draw a puppy' }); + const reason = { type: ToolConfirmKind.Skipped, source: 'user' } as const; + const details: IToolResultInputOutputDetails = { + input: '{"prompt":"Draw a puppy"}', + output: [{ type: 'embed', value: 'Stopped by the user', isText: true }], + isError: true, + }; + invocation.didCancelTool(reason, 'Stopped by the user', details); + const cancelledState = invocation.state.get(); + await invocation.didExecuteTool({ content: [] }); + invocation.didCancelTool({ type: ToolConfirmKind.Denied }); + assert.deepStrictEqual({ + state: invocation.state.get().type, + sameState: invocation.state.get() === cancelledState, + confirmation: IChatToolInvocation.executionConfirmedOrDenied(invocation), + details: IChatToolInvocation.resultDetails(invocation), + serializedDetails: invocation.toJSON().resultDetails, + }, { + state: IChatToolInvocation.StateKind.Cancelled, + sameState: true, + confirmation: reason, + details, + serializedDetails: details, + }); + }); + test('token cancellation and legacy denials remain unattributed', async () => { const invocation = new ChatToolInvocation(prepared, tool, 'call', undefined, {}); const token = disposables.add(new CancellationTokenSource()); diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts b/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts index 82c0d03e50b061..79468b8ce163b6 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts @@ -2865,7 +2865,7 @@ export class ExtensionStatusAction extends ExtensionAction { const delay = fromNow(Date.now() - this.extensionsWorkbenchService.getAutoUpdateDelay(), false, true); const updateAt = fromNow(Date.now() + this.extensionsWorkbenchService.getAutoUpdateDelayRemaining(this.extension), false, true); // Do not override the higher-priority warning class with the info class. - this.updateStatus({ icon: infoIcon, message: new MarkdownString(localize('autoUpdateDelayed', "This extension is not updated yet because new versions are auto updated {0} after they are published. It will be auto updated {1}.", delay, updateAt)) }, !hasConsentWarning); + this.updateStatus({ icon: infoIcon, message: new MarkdownString(localize('autoUpdateDelayed', "This extension is not updated yet because new versions become eligible for automatic update {0} after they are published. It will become eligible {1}.", delay, updateAt)) }, !hasConsentWarning); } } diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts index b60ffca7db1ed4..2e1a7081f8902d 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts @@ -7,7 +7,7 @@ import * as nls from '../../../../nls.js'; import * as semver from '../../../../base/common/semver/semver.js'; import { Event, Emitter } from '../../../../base/common/event.js'; import { index } from '../../../../base/common/arrays.js'; -import { CancelablePromise, Promises, ThrottledDelayer, createCancelablePromise, disposableTimeout } from '../../../../base/common/async.js'; +import { CancelablePromise, Promises, ThrottledDelayer, createCancelablePromise } from '../../../../base/common/async.js'; import { CancellationError, getErrorMessage, isCancellationError } from '../../../../base/common/errors.js'; import { Disposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { IPager, singlePagePager } from '../../../../base/common/paging.js'; @@ -999,7 +999,6 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension private installing: IExtension[] = []; private tasksInProgress: CancelablePromise[] = []; - private readonly delayedAutoUpdateCheckTimer = this._register(new MutableDisposable()); readonly whenInitialized: Promise; @@ -1135,19 +1134,13 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension // Register listeners for auto updates this._register(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(AutoUpdateConfigurationKey)) { - if (!this.isAutoUpdateEnabled()) { - // Auto update disabled — cancel any pending delayed re-check - this.delayedAutoUpdateCheckTimer.value = undefined; - } else { + if (this.isAutoUpdateEnabled()) { this.eventuallyAutoUpdateExtensions(); } // The auto update value affects whether an extension is shown as delayed this._onChange.fire(undefined); } if (e.affectsConfiguration(AutoUpdateDelayConfigurationKey)) { - // The delay affects when delayed updates are applied — cancel any pending - // delayed re-check and re-run the scheduling path with the new delay. - this.delayedAutoUpdateCheckTimer.value = undefined; if (this.isAutoUpdateEnabled()) { this.eventuallyAutoUpdateExtensions(); } @@ -2280,7 +2273,6 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension const toUpdate: IExtension[] = []; const disabledAutoUpdate = []; const consentRequired = []; - let soonestDelayRemaining = Number.MAX_SAFE_INTEGER; for (const extension of this.outdated) { if (!this.shouldAutoUpdateExtension(extension)) { disabledAutoUpdate.push(extension.identifier.id); @@ -2291,7 +2283,6 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension const delayRemaining = this.getAutoUpdateDelayRemaining(extension); if (delayRemaining > 0) { this.logService.trace('Auto update delayed for extension', extension.identifier.id); - soonestDelayRemaining = Math.min(soonestDelayRemaining, delayRemaining); continue; } } @@ -2302,12 +2293,6 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension toUpdate.push(extension); } - if (soonestDelayRemaining < Number.MAX_SAFE_INTEGER) { - this.delayedAutoUpdateCheckTimer.value = disposableTimeout(() => this.eventuallyCheckForUpdates(true), soonestDelayRemaining); - } else { - this.delayedAutoUpdateCheckTimer.value = undefined; - } - if (disabledAutoUpdate.length) { this.logService.trace('Auto update disabled for extensions', disabledAutoUpdate.join(', ')); } diff --git a/src/vs/workbench/contrib/extensions/test/electron-browser/extensionsWorkbenchService.test.ts b/src/vs/workbench/contrib/extensions/test/electron-browser/extensionsWorkbenchService.test.ts index 65a7e0f251a156..c352089532af57 100644 --- a/src/vs/workbench/contrib/extensions/test/electron-browser/extensionsWorkbenchService.test.ts +++ b/src/vs/workbench/contrib/extensions/test/electron-browser/extensionsWorkbenchService.test.ts @@ -5,7 +5,9 @@ import * as sinon from 'sinon'; import assert from 'assert'; +import { timeout } from '../../../../../base/common/async.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; +import { runWithFakedTimers } from '../../../../../base/test/common/virtualScheduling/index.js'; import { ExtensionState, AutoCheckUpdatesConfigurationKey, AutoUpdateConfigurationKey, AutoUpdateDelayConfigurationKey, ExtensionRuntimeActionType, AutoUpdateConfigurationValue } from '../../common/extensions.js'; import { ExtensionsWorkbenchService } from '../../browser/extensionsWorkbenchService.js'; import { @@ -496,6 +498,31 @@ suite('ExtensionsWorkbenchServiceTest', () => { assert.strictEqual(testObject.isAutoUpdateDelayed(testObject.local[0]), false); }); + test('test delayed auto update does not reschedule the marketplace update check', () => runWithFakedTimers({ useFakeTimers: true, startTime: 1000 * 60 * 60 * 3 }, async () => { + const local = aLocalExtension('a', { version: '1.0.1' }); + const gallery = aGalleryExtension(local.manifest.name, { + identifier: local.identifier, + version: '1.0.2', + lastUpdated: Date.now() - (1000 * 60 * 60) + }); + let updateCheckCount = 0; + instantiationService.stubPromise(IExtensionManagementService, 'getInstalled', [local]); + instantiationService.stub(IExtensionGalleryService, 'getExtensions', async () => { + updateCheckCount++; + return [gallery]; + }); + + testObject = await aWorkbenchService(); + await testObject.checkForUpdates(); + await timeout(1001); + const updateCheckCountBeforeQuarantineExpires = updateCheckCount; + + await timeout(1000 * 60 * 60); + + assert.strictEqual(updateCheckCount, updateCheckCountBeforeQuarantineExpires); + testObject.dispose(); + })); + test('test getAutoUpdateValue normalizes legacy and migrated values', async () => { const expected = new Map([ ['on', 'on'], diff --git a/src/vs/workbench/contrib/onboarding/README.md b/src/vs/workbench/contrib/onboarding/README.md index 7b04becee7732c..e4f799936e5cec 100644 --- a/src/vs/workbench/contrib/onboarding/README.md +++ b/src/vs/workbench/contrib/onboarding/README.md @@ -195,6 +195,8 @@ Use: Spotlights are keyboard operable, dismissible with Escape, and keep interactive targets available when requested. Important state changes remain the responsibility of the owning feature. +For a selection-driven step, `primaryAction` can offer the same action through a labeled callout button. It runs only on an explicit button click and does not advance the tour itself: use `advanceOnTargetSelection` or `advanceWhen` to confirm the outcome. The button is disabled after activation, and the action's cancellation token is cancelled when the step ends or its target is replaced. Check that token after asynchronous preparation and before changing state. End Tour remains available even on the final action step. + ## Own and scope spotlight targets Feature-owned adapters can register target providers that resolve controls through their owner's API: diff --git a/src/vs/workbench/contrib/onboarding/browser/media/spotlight.css b/src/vs/workbench/contrib/onboarding/browser/media/spotlight.css index a9a0777f544e56..74808f79c534c4 100644 --- a/src/vs/workbench/contrib/onboarding/browser/media/spotlight.css +++ b/src/vs/workbench/contrib/onboarding/browser/media/spotlight.css @@ -13,11 +13,28 @@ z-index: 2600; } -/* Expanded pickers and mobile sheets remain above the spotlight blocker. */ +/* + * Expanded pickers stay above the scrim, but their full-window dismiss layers + * must not cover the callout's actions. Avoid a shared stacking context here. + */ .spotlight-overlay.target-overlay-visible { + position: static; + z-index: auto; +} + +.spotlight-overlay.target-overlay-visible .spotlight-blocker, +.spotlight-overlay.target-overlay-visible .spotlight-hole { z-index: 1999; } +.spotlight-overlay.target-overlay-visible .spotlight-callout { + z-index: 2600; +} + +.spotlight-overlay.target-overlay-visible .spotlight-callout-pointer { + z-index: 2601; +} + /* Full-screen click blocker. Transparent; swallows interaction outside the hole. */ .spotlight-blocker { position: fixed; diff --git a/src/vs/workbench/contrib/onboarding/browser/spotlight/onboardingTarget.ts b/src/vs/workbench/contrib/onboarding/browser/spotlight/onboardingTarget.ts index 93b83a8a68a9be..c18df728172beb 100644 --- a/src/vs/workbench/contrib/onboarding/browser/spotlight/onboardingTarget.ts +++ b/src/vs/workbench/contrib/onboarding/browser/spotlight/onboardingTarget.ts @@ -34,6 +34,8 @@ export interface IOnboardingTargetOptions { export interface IOnboardingTarget { readonly element: HTMLElement; readonly open?: () => Promise | void; + /** Accepted selections supplied by a scoped adapter, without replacing the owner's DOM marker. */ + readonly onDidSelect?: Event>; } interface IOnboardingTargetRegistration { @@ -67,6 +69,7 @@ export function resolveOnboardingTarget(targetWindow: Window, id: string, scope? } return { element: target.element, + onDidSelect: target.onDidSelect, open: () => { if (onboardingTargetProviders.get(id) === provider) { const current = provider.resolve(scope); diff --git a/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightOverlay.ts b/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightOverlay.ts index 27d06090445c67..101743876fe391 100644 --- a/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightOverlay.ts +++ b/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightOverlay.ts @@ -45,6 +45,8 @@ export interface ISpotlightContent { readonly canGoBack: boolean; /** Whether this is the final step (the primary button becomes "Done"). */ readonly isLastStep: boolean; + /** Keeps cancellation available when the final primary button performs an action. */ + readonly showEndTour?: boolean; } /** Options controlling how a step is shown. */ @@ -173,6 +175,7 @@ export class SpotlightOverlay extends Disposable { this._target = target; this._options = options; + this.setPrimaryActionEnabled(true); this._renderContent(content); const externalUiParticipates = !!options.targetOverlayVisible || !!options.allowTargetInteraction || !!options.advanceOnTargetClick || !!options.hideNext; this._root.classList.toggle('target-overlay-visible', externalUiParticipates); @@ -267,6 +270,11 @@ export class SpotlightOverlay extends Disposable { (hideNext ? target : this._nextButton.element).focus(); } + /** Prevents duplicate activation while waiting for an action to be accepted. */ + setPrimaryActionEnabled(enabled: boolean): void { + this._nextButton.enabled = enabled; + } + /** Hide the current step while another target is being resolved. */ hide(): void { this._stepListeners.clear(); @@ -434,7 +442,7 @@ export class SpotlightOverlay extends Disposable { ? localize('spotlight.counter', "{0} of {1}", content.stepIndex + 1, content.stepCount) : ''; - this._skipButton.element.style.display = content.isLastStep ? 'none' : ''; + this._skipButton.element.style.display = content.isLastStep && !content.showEndTour ? 'none' : ''; this._backButton.element.style.display = content.canGoBack ? '' : 'none'; this._nextButton.label = content.nextButtonLabel ?? (content.isLastStep ? localize('spotlight.done', "Done") diff --git a/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightPresentation.ts b/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightPresentation.ts index e96d9784e04ce1..8a2c1dd639ab4d 100644 --- a/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightPresentation.ts +++ b/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightPresentation.ts @@ -320,7 +320,10 @@ export class SpotlightPresentation extends Disposable implements IOnboardingPres private async _showStep(overlay: SpotlightOverlay, context: IOnboardingRunContext, step: ISpotlightStep, target: IOnboardingTarget, index: number, stepCount: number, canGoBack: boolean, isLastStep: boolean): Promise { const stepStore = new DisposableStore(); + const cancellation = new CancellationTokenSource(); + stepStore.add(toDisposable(() => cancellation.dispose(true))); let ended = false; + let actionStarted = false; let resolveStep: (end: StepEnd) => void; const result = new Promise(resolve => resolveStep = resolve); const done = (end: StepEnd) => { @@ -332,7 +335,25 @@ export class SpotlightPresentation extends Disposable implements IOnboardingPres resolveStep(end); }; - stepStore.add(overlay.onDidClickNext(via => done({ action: 'next', via }))); + stepStore.add(overlay.onDidClickNext(async via => { + if (!step.primaryAction || via !== 'button') { + done({ action: 'next', via }); + return; + } + if (ended || actionStarted) { + return; + } + actionStarted = true; + overlay.setPrimaryActionEnabled(false); + try { + await step.primaryAction.run(cancellation.token); + } catch (error) { + if (!ended) { + onUnexpectedError(error); + done({ action: 'abort' }); + } + } + })); stepStore.add(overlay.onDidClickPrevious(() => done({ action: 'back' }))); stepStore.add(overlay.onDidSkip(reason => done({ action: 'skip', reason }))); stepStore.add(overlay.onDidLoseTarget(() => done({ action: 'missingTarget' }))); @@ -341,16 +362,17 @@ export class SpotlightPresentation extends Disposable implements IOnboardingPres const content: ISpotlightContent = { title: step.title, description: step.description, - nextButtonLabel: step.nextButtonLabel, + nextButtonLabel: step.primaryAction?.label ?? step.nextButtonLabel, stepIndex: index, stepCount, canGoBack, isLastStep, + showEndTour: !!step.primaryAction, }; const openTarget = step.openTarget === 'ifUnselected' ? !hasOnboardingTargetSelection(target.element) : step.openTarget; if (step.advanceOnTargetSelection) { - stepStore.add(onDidSelectOnboardingTarget(target.element)(async accepted => { + stepStore.add((target.onDidSelect ?? onDidSelectOnboardingTarget(target.element))(async accepted => { try { if (await accepted) { done({ action: 'next', via: 'condition' }); @@ -366,11 +388,12 @@ export class SpotlightPresentation extends Disposable implements IOnboardingPres placement: step.placement, allowTargetInteraction: step.allowTargetInteraction, advanceOnTargetClick: step.advanceOnTargetClick, - hideNext: step.advanceWhen ? true : step.hideNext, + hideNext: step.primaryAction ? false : step.advanceWhen ? true : step.hideNext, targetOverlayVisible: openTarget, padding: step.padding, }); context.onDidShow?.(); + step.onDidShow?.(); if (step.advanceWhen) { const keys = new Set(step.advanceWhen.keys()); diff --git a/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightTypes.ts b/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightTypes.ts index 215095ea37e234..0e24afa3eae571 100644 --- a/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightTypes.ts +++ b/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightTypes.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { IMarkdownString } from '../../../../../base/common/htmlContent.js'; import { ContextKeyExpression } from '../../../../../platform/contextkey/common/contextkey.js'; @@ -46,6 +47,16 @@ export interface ISpotlightStep { /** Localized primary button label, replacing the default Next or Done. */ readonly nextButtonLabel?: string; + /** + * Explicit action offered instead of Next or Done. Only runs on a button + * click; advancing still requires target selection or `advanceWhen`. + * The token is cancelled when the step ends or its target is replaced. + */ + readonly primaryAction?: { + readonly label: string; + readonly run: (token: CancellationToken) => Promise | void; + }; + /** Preferred placement of the callout. Defaults to `'auto'`. */ readonly placement?: SpotlightPlacement; @@ -67,7 +78,7 @@ export interface ISpotlightStep { /** Advances when the target reports a selection, without hiding Next. */ readonly advanceOnTargetSelection?: boolean; - /** Overrides hiding Next when advancing on target clicks. `advanceWhen` always hides Next. */ + /** Overrides hiding Next when advancing on target clicks. `primaryAction` always stays visible. */ readonly hideNext?: boolean; /** Hides Next and advances once this context expression becomes satisfied. */ @@ -81,6 +92,9 @@ export interface ISpotlightStep { * that hosts the target. Awaited before the target is resolved. */ readonly onBeforeShow?: () => Promise | void; + + /** Called after the spotlight is visible, for presentation-only effects. */ + readonly onDidShow?: () => void; } /** diff --git a/src/vs/workbench/contrib/onboarding/test/browser/spotlightOverlay.test.ts b/src/vs/workbench/contrib/onboarding/test/browser/spotlightOverlay.test.ts index bb16fb189c7be3..a69b4d8ba1f170 100644 --- a/src/vs/workbench/contrib/onboarding/test/browser/spotlightOverlay.test.ts +++ b/src/vs/workbench/contrib/onboarding/test/browser/spotlightOverlay.test.ts @@ -159,6 +159,48 @@ suite('SpotlightOverlay', () => { assert.deepStrictEqual(fired, ['skip', 'back', 'next']); }); + test('primary actions and cancellation remain clickable above an expanded picker', () => { + const container = createContainer(); + const overlay = disposables.add(new SpotlightOverlay(container, FakeResizeObserver)); + const target = createTarget(container, 100, 100, 80, 30); + const pickerBlocker = $('div'); + pickerBlocker.style.cssText = 'position: fixed; inset: 0; z-index: 2576;'; + container.appendChild(pickerBlocker); + overlay.show(target, content({ isLastStep: true, nextButtonLabel: 'Use Copilot', showEndTour: true }), { targetOverlayVisible: true, allowTargetInteraction: true }); + + const buttons = getButtons(container); + const hitTest = (button: HTMLElement) => { + const rect = button.getBoundingClientRect(); + return button.contains(mainWindow.document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2)); + }; + assert.deepStrictEqual({ + labels: buttons.map(button => button.textContent), + clickable: buttons.map(hitTest), + pickerClickable: mainWindow.document.elementFromPoint(10, 10) === pickerBlocker, + }, { + labels: ['End Tour', 'Back', 'Use Copilot'], + clickable: [true, true, true], + pickerClickable: true, + }); + }); + + test('disables a pending primary action and resets it for the next step', () => { + const container = createContainer(); + const overlay = disposables.add(new SpotlightOverlay(container, FakeResizeObserver)); + const target = createTarget(container, 100, 100, 80, 30); + let actions = 0; + disposables.add(overlay.onDidClickNext(() => actions++)); + overlay.show(target, content()); + const [, , primary] = getButtons(container); + overlay.setPrimaryActionEnabled(false); + primary.click(); + assert.deepStrictEqual({ actions, disabled: primary.getAttribute('aria-disabled') }, { actions: 0, disabled: 'true' }); + + overlay.show(target, content()); + primary.click(); + assert.deepStrictEqual({ actions, disabled: primary.getAttribute('aria-disabled') }, { actions: 1, disabled: 'false' }); + }); + test('advanceOnTargetClick hides Next and advances when the target is clicked', async () => { const container = createContainer(); const overlay = disposables.add(new SpotlightOverlay(container, FakeResizeObserver as unknown as typeof ResizeObserver)); diff --git a/src/vs/workbench/contrib/onboarding/test/browser/spotlightPresentation.test.ts b/src/vs/workbench/contrib/onboarding/test/browser/spotlightPresentation.test.ts index 2b776d4b1f4005..54bccc6dacd7de 100644 --- a/src/vs/workbench/contrib/onboarding/test/browser/spotlightPresentation.test.ts +++ b/src/vs/workbench/contrib/onboarding/test/browser/spotlightPresentation.test.ts @@ -65,6 +65,98 @@ suite('SpotlightPresentation', () => { }; } + test('a primary action runs only on click and waits for accepted selection', async () => { + const container = createContainer(); + const contextKeys = disposables.add(new ContextKeyService(new TestConfigurationService())); + const presentation = disposables.add(new SpotlightPresentation(new SpotlightTestLayoutService(container), new TestHostService(), contextKeys)); + const selected = disposables.add(new Emitter>()); + const shown = new DeferredPromise(); + const accepted = new DeferredPromise(); + createTarget(container, 'test.action', { onDidSelect: selected.event }); + let actions = 0; + let completed = false; + const result = presentation.run(createScenario('test.action', { + id: 'action', targetId: 'test.action', title: 'Choose', description: 'Choose a model.', + advanceOnTargetSelection: true, hideNext: true, + primaryAction: { label: 'Use Copilot', run: () => { actions++; } }, + }), { targetWindow: mainWindow, onAbort: Event.None, onDidShow: () => { void shown.complete(); } }); + void result.then(() => { completed = true; }); + await shown.p; + const buttons = Array.from(container.querySelectorAll('.monaco-button')); + assert.deepStrictEqual({ actions, visibleButtons: buttons.filter(button => button.style.display !== 'none').map(button => button.textContent) }, { + actions: 0, visibleButtons: ['End Tour', 'Use Copilot'], + }); + buttons[2].click(); + buttons[2].click(); + selected.fire(accepted.p); + await Promise.resolve(); + assert.deepStrictEqual({ actions, completed }, { actions: 1, completed: false }); + await accepted.complete(true); + assert.strictEqual((await result).outcome, OnboardingOutcome.Completed); + }); + + for (const exit of ['back', 'endTour', 'abort', 'replace'] as const) { + test(`cancels a pending primary action on ${exit} and ignores its late result`, async () => { + const container = createContainer(); + const contextKeys = disposables.add(new ContextKeyService(new TestConfigurationService())); + const presentation = disposables.add(new SpotlightPresentation(new SpotlightTestLayoutService(container), new TestHostService(), contextKeys)); + const abort = disposables.add(new Emitter()); + const shown = new DeferredPromise(); + const action = new DeferredPromise(); + const cancelled = new DeferredPromise(); + const target = createTarget(container, 'test.cancelAction'); + let actionToken: CancellationToken | undefined; + const result = presentation.runStep({ + id: 'action', kind: SPOTLIGHT_PRESENTATION_KIND, + payload: { + id: 'action', targetId: 'test.cancelAction', title: 'Choose', description: 'Choose a model.', + missingTarget: { kind: 'abort' }, + primaryAction: { + label: 'Use Copilot', run: token => { + actionToken = token; + disposables.add(token.onCancellationRequested(() => { void cancelled.complete(); })); + return action.p; + }, + }, + } satisfies ISpotlightStep, + }, { + targetWindow: mainWindow, onAbort: abort.event, onDidShow: () => { void shown.complete(); }, + cancellationToken: CancellationToken.None, stepIndex: 1, visualStepIndex: 1, visualStepCount: 2, canGoBack: true, isLastVisualStep: true, + }); + await shown.p; + const [endTour, back, primary] = Array.from(container.querySelectorAll('.monaco-button')); + primary.click(); + if (exit === 'back') { back.click(); } + else if (exit === 'endTour') { endTour.click(); } + else if (exit === 'abort') { abort.fire(); } + else { target.remove(); } + await cancelled.p; + await action.complete(); + assert.deepStrictEqual({ cancelled: actionToken?.isCancellationRequested, action: (await result).action }, { + cancelled: true, action: exit === 'back' ? 'back' : exit === 'endTour' ? 'skipSequence' : 'abort', + }); + }); + } + + test('a scoped adapter reports selection without replacing the owner target marker', async () => { + const container = createContainer(); + const contextKeys = disposables.add(new ContextKeyService(new TestConfigurationService())); + const presentation = disposables.add(new SpotlightPresentation(new SpotlightTestLayoutService(container), new TestHostService(), contextKeys)); + const selected = disposables.add(new Emitter>()); + const target = createTarget(container, 'test.owner'); + disposables.add(registerOnboardingTargetProvider('test.adapter', () => ({ element: target, onDidSelect: selected.event }))); + const result = await presentation.run(createScenario('test.adapter', { + id: 'choose', targetId: 'test.adapter', title: 'Open Session', description: 'Use the real row.', + allowTargetInteraction: true, advanceOnTargetSelection: true, hideNext: true, + }), { + targetWindow: mainWindow, onAbort: Event.None, + onDidShow: () => selected.fire(Promise.resolve(true)), + }); + assert.deepStrictEqual({ outcome: result.outcome, marker: target.getAttribute(ONBOARDING_TARGET_ATTR), retainedListener: selected.hasListeners() }, { + outcome: OnboardingOutcome.Completed, marker: 'test.owner', retainedListener: false, + }); + }); + test('resolves a target within the prepared instance scope', async () => { const container = createContainer(); const contextKeyService = disposables.add(new ContextKeyService(new TestConfigurationService())); diff --git a/src/vs/workbench/contrib/timeline/browser/timelinePane.ts b/src/vs/workbench/contrib/timeline/browser/timelinePane.ts index 813c9ba398d254..6c277be6e7dcd6 100644 --- a/src/vs/workbench/contrib/timeline/browser/timelinePane.ts +++ b/src/vs/workbench/contrib/timeline/browser/timelinePane.ts @@ -410,6 +410,10 @@ export class TimelinePane extends ViewPane { private onProvidersChanged(e: TimelineProvidersChangeEvent) { if (e.removed) { for (const source of e.removed) { + const pendingRequest = this.pendingRequests.get(source); + this.pendingRequests.delete(source); + pendingRequest?.request.tokenSource.cancel(); + pendingRequest?.dispose(); this.timelinesBySource.delete(source); } @@ -620,7 +624,11 @@ export class TimelinePane extends ViewPane { const disposables = new DisposableStore(); this.pendingRequests.set(source, { request: newRequest, dispose: () => disposables.dispose() }); disposables.add(tokenSource); - disposables.add(tokenSource.token.onCancellationRequested(() => this.pendingRequests.delete(source))); + disposables.add(tokenSource.token.onCancellationRequested(() => { + if (this.pendingRequests.get(source)?.request === newRequest) { + this.pendingRequests.delete(source); + } + })); this.handleRequest(newRequest); @@ -650,11 +658,13 @@ export class TimelinePane extends ViewPane { // Ignore } - // If the request was cancelled then it was already deleted from the pendingRequests map - if (!request.tokenSource.token.isCancellationRequested) { - this.pendingRequests.get(request.source)?.dispose(); - this.pendingRequests.delete(request.source); + // Ignore cancelled or replaced requests, including results from removed providers. + const pendingRequest = this.pendingRequests.get(request.source); + if (pendingRequest?.request !== request) { + return; } + this.pendingRequests.delete(request.source); + pendingRequest.dispose(); if (response === undefined || request.uri !== this.uri) { if (this.pendingRequests.size === 0 && this._pendingRefresh) { diff --git a/src/vs/workbench/contrib/timeline/test/browser/timelinePane.test.ts b/src/vs/workbench/contrib/timeline/test/browser/timelinePane.test.ts new file mode 100644 index 00000000000000..a86187c4250e3a --- /dev/null +++ b/src/vs/workbench/contrib/timeline/test/browser/timelinePane.test.ts @@ -0,0 +1,139 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { DeferredPromise, timeout } from '../../../../../base/common/async.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; +import { IProgressService, Progress } from '../../../../../platform/progress/common/progress.js'; +import { TimelinePane } from '../../browser/timelinePane.js'; +import { ITimelineService, Timeline, TimelineRequest } from '../../common/timeline.js'; + +interface TestTimelinePane { + clear: TimelinePane['clear']; + loadTimelineForSource: TimelinePane['loadTimelineForSource']; + onProvidersChanged: TimelinePane['onProvidersChanged']; + loadMore: TimelinePane['loadMore']; + refresh: TimelinePane['refresh']; + pendingRequests: TimelinePane['pendingRequests']; + timelinesBySource: TimelinePane['timelinesBySource']; +} + +suite('TimelinePane', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + const uri = URI.file('/timeline.txt'); + + function createPane() { + const results = new Map>(); + const requests: TimelineRequest[] = []; + let children: Parameters[1] = []; + const pane: TestTimelinePane = Object.assign(Object.create(TimelinePane.prototype), { + uri, + pendingRequests: new Map(), + timelinesBySource: new Map(), + excludedSources: new Set(), + _maxItemCount: 20, + _visibleItemCount: 0, + _pendingRefresh: false, + _isEmpty: true, + $message: document.createElement('div'), + isBodyVisible: () => true, + updateFilename: () => { }, + configurationService: new TestConfigurationService(), + labelService: upcastPartial({ getUriBasenameLabel: () => 'timeline.txt' }), + progressService: upcastPartial({ withProgress: (_options, task) => task(Progress.None) }), + tree: new class extends mock() { + override setChildren(_element: null, elements: Parameters[1]): void { + children = [...elements ?? []]; + } + override rerender(): void { } + }, + timelineService: upcastPartial({ + getSources: () => [...results.keys()].map(id => ({ id, label: id })), + getTimeline: (source, uri, options, tokenSource) => { + const request = { source, uri, options, tokenSource, result: results.get(source)! }; + requests.push(request); + return request; + }, + }), + }); + store.add({ dispose: () => pane.clear(true) }); + return { + pane, results, requests, + getLoadMore: () => { + const element = [...children ?? []].find(child => child.element.handle === 'vscode-command:loadMore')!.element; + return element as Parameters[0]; + }, + }; + } + + async function addPageableProvider(pane: TestTimelinePane, results: Map>) { + results.set('B', Promise.resolve({ + source: 'B', + items: [{ handle: 'B|first', source: 'B', label: 'First item', timestamp: 1 }], + paging: { cursor: 'next' }, + })); + pane.loadTimelineForSource('B', uri, true); + await timeout(0); + } + + test('removing a pending provider releases pagination for a surviving provider', async () => { + const { pane, results, requests, getLoadMore } = createPane(); + await addPageableProvider(pane, results); + const deferred = new DeferredPromise(); + results.set('A', deferred.p); + pane.loadTimelineForSource('A', uri, true); + const removedRequest = requests[requests.length - 1]; + + results.delete('A'); + pane.onProvidersChanged({ removed: ['A'] }); + assert.strictEqual(getLoadMore().loading, false); + // The extension host discards results from a disposed provider. + await deferred.complete(undefined); + await timeout(0); + + const loadMore = getLoadMore(); + assert.strictEqual(loadMore.loading, false); + assert.strictEqual(removedRequest.tokenSource.token.isCancellationRequested, true); + results.set('B', new DeferredPromise().p); + pane.loadMore(loadMore); + assert.deepStrictEqual(requests.map(request => [request.source, request.options.cursor]), [['B', undefined], ['A', undefined], ['B', 'next']]); + }); + + for (const hasStaleItems of [false, true]) { + test(`late ${hasStaleItems ? 'nonempty' : 'undefined'} result cannot affect a replacement provider request`, async () => { + const { pane, results, requests, getLoadMore } = createPane(); + await addPageableProvider(pane, results); + const oldResult = new DeferredPromise(); + results.set('A', oldResult.p); + pane.loadTimelineForSource('A', uri, true); + pane.onProvidersChanged({ removed: ['A'] }); + + const replacementResult = new DeferredPromise(); + results.set('A', replacementResult.p); + pane.onProvidersChanged({ added: ['A'] }); + const replacementRequest = requests[requests.length - 1]; + pane.refresh(); + await oldResult.complete(hasStaleItems ? { + source: 'A', items: [{ handle: 'A|stale', source: 'A', label: 'Stale item', timestamp: 3 }], + } : undefined); + await timeout(0); + + assert.strictEqual(pane.pendingRequests.get('A')?.request, replacementRequest); + assert.strictEqual(pane.timelinesBySource.has('A'), false); + assert.strictEqual(getLoadMore().loading, true); + + await replacementResult.complete({ + source: 'A', items: [{ handle: 'A|replacement', source: 'A', label: 'Replacement item', timestamp: 2 }], + }); + await timeout(0); + assert.deepStrictEqual(pane.timelinesBySource.get('A')?.items.map(item => item.handle), ['A|replacement']); + assert.strictEqual(getLoadMore().loading, false); + }); + } +}); diff --git a/src/vs/workbench/services/agentHost/browser/codexContinuation.ts b/src/vs/workbench/services/agentHost/browser/codexContinuation.ts new file mode 100644 index 00000000000000..d077f7e9e37741 --- /dev/null +++ b/src/vs/workbench/services/agentHost/browser/codexContinuation.ts @@ -0,0 +1,94 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AgentSession, IAgentSessionMetadata } from '../../../../platform/agentHost/common/agent.js'; +import { CHATGPT_SUBSCRIPTION_MODEL_SOURCE_ID, readAgentModelSourceId } from '../../../../platform/agentHost/common/agentModelSource.js'; +import { ICodexAccountInfo } from '../../../../platform/agentHost/common/codexAccount.js'; +import { isPaidChatGPTPlan } from '../../../../platform/agentHost/common/codexAccountPlan.js'; +import { parseCodexModelSelection, toCodexModelSelectionId } from '../../../../platform/agentHost/common/codexModelSelection.js'; +import { isSessionStatusArchived, SessionModelInfo } from '../../../../platform/agentHost/common/state/sessionState.js'; + +export const CODEX_CONTINUATION_MAX_AGE = 5 * 60 * 1000; +export const CODEX_CONTINUATION_DEFAULT_THRESHOLD = 90; +export type CodexContinuationSurface = 'agentsWindow' | 'editorWindow'; +export interface ICodexLimitBoundary { + readonly duration: number; + readonly until: number; + readonly observedAt: number; + readonly reliable: boolean; + /** Threshold when this boundary was shown; omitted by older, fixed-90-percent episodes. */ + readonly thresholdPercent?: number; +} +export interface ICodexContinuationEpisode { + readonly limits: readonly ICodexLimitBoundary[]; + readonly owner: string; + readonly surface: CodexContinuationSurface; +} +export interface ICodexContinuationCandidate { + readonly session: IAgentSessionMetadata; + readonly source: SessionModelInfo; + readonly target: SessionModelInfo; +} + +export function getCodexTriggeringLimits(account: ICodexAccountInfo, now: number, thresholdPercent = CODEX_CONTINUATION_DEFAULT_THRESHOLD): ICodexLimitBoundary[] { + const observedAt = account.observedAt; + if (account.status !== 'signedIn' || !isPaidChatGPTPlan(account.planType) + || observedAt === undefined || !Number.isFinite(observedAt) || observedAt > now || now - observedAt > CODEX_CONTINUATION_MAX_AGE) { + return []; + } + return (account.rateLimits ?? (account.rateLimit ? [account.rateLimit] : [])).flatMap(limit => { + const duration = limit.windowDurationMins; + if ((duration !== 300 && duration !== 10080) || !Number.isFinite(limit.usedPercent) || limit.usedPercent < thresholdPercent || limit.usedPercent > 100) { + return []; + } + const until = limit.resetsAt !== undefined ? limit.resetsAt * 1000 : observedAt + duration * 60 * 1000; + return Number.isFinite(until) && until > now ? [{ duration, until, observedAt, reliable: limit.resetsAt !== undefined, thresholdPercent }] : []; + }); +} + +/** + * Overlapping windows extend an episode; unknown resets never slide on repeated observations. + * Recovery uses the saved threshold because other windows can have different assignments. + */ +export function updateCodexEpisode(episode: ICodexContinuationEpisode | undefined, account: ICodexAccountInfo, now: number, thresholdPercent = CODEX_CONTINUATION_DEFAULT_THRESHOLD): ICodexContinuationEpisode | undefined { + if (!episode) { + return undefined; + } + const fresh = account.observedAt !== undefined && account.observedAt <= now && now - account.observedAt <= CODEX_CONTINUATION_MAX_AGE; + const retained = episode.limits.filter(boundary => boundary.until > now && (boundary.reliable || !fresh || !(account.rateLimits ?? (account.rateLimit ? [account.rateLimit] : [])).some(limit => + limit.windowDurationMins === boundary.duration && limit.usedPercent < (boundary.thresholdPercent ?? CODEX_CONTINUATION_DEFAULT_THRESHOLD) && account.observedAt! > boundary.observedAt))); + if (!retained.length) { + return undefined; + } + for (const boundary of getCodexTriggeringLimits(account, now, thresholdPercent)) { + const index = retained.findIndex(previous => previous.duration === boundary.duration); + if (index < 0) { + retained.push(boundary); + } else if (boundary.reliable && boundary.until > retained[index].until) { + retained[index] = boundary; + } + } + return { ...episode, limits: retained }; +} + +export function getCodexContinuationCandidates(sessions: readonly IAgentSessionMetadata[], models: readonly SessionModelInfo[], activeSession?: string): ICodexContinuationCandidate[] { + const candidates: ICodexContinuationCandidate[] = []; + for (const session of sessions) { + if ((session.provider ?? AgentSession.provider(session.session)) !== 'codex' || !session.model || isSessionStatusArchived(session.status)) { + continue; + } + const selection = parseCodexModelSelection(session.model); + const source = models.find(model => model.id === session.model?.id); + if (selection.modelProvider !== 'openai' || !source || readAgentModelSourceId(source) !== CHATGPT_SUBSCRIPTION_MODEL_SOURCE_ID) { + continue; + } + const target = models.find(model => model.id === toCodexModelSelectionId('vscode-proxy', selection.modelId) && model.policyState !== 'disabled'); + if (target) { + candidates.push({ session, source, target }); + } + } + return candidates.sort((a, b) => Number(b.session.session.toString() === activeSession) - Number(a.session.session.toString() === activeSession) + || b.session.modifiedTime - a.session.modifiedTime); +} diff --git a/src/vs/workbench/services/agentHost/browser/codexContinuationService.ts b/src/vs/workbench/services/agentHost/browser/codexContinuationService.ts new file mode 100644 index 00000000000000..4f3dfd2fbac081 --- /dev/null +++ b/src/vs/workbench/services/agentHost/browser/codexContinuationService.ts @@ -0,0 +1,362 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { RunOnceScheduler, Sequencer } from '../../../../base/common/async.js'; +import { onUnexpectedError } from '../../../../base/common/errors.js'; +import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { derived, IObservable, observableValue } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { IAgentHostService } from '../../../../platform/agentHost/common/agentService.js'; +import { IAgentSessionMetadata } from '../../../../platform/agentHost/common/agent.js'; +import { IAgentHostConnectionsService } from '../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { CODEX_ACCOUNT_REFRESH_REQUEST_KEY } from '../../../../platform/agentHost/common/codexAccount.js'; +import { ROOT_STATE_URI } from '../../../../platform/agentHost/common/state/sessionState.js'; +import { ActionType } from '../../../../platform/agentHost/common/state/sessionActions.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { IStorageService, StorageScope } from '../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { logSettingExperimentTrigger } from '../../../../platform/telemetry/common/experimentTrigger.js'; +import { hasUsableCopilotPremiumQuota, IChatEntitlementService } from '../../chat/common/chatEntitlementService.js'; +import { IWorkbenchEnvironmentService } from '../../environment/common/environmentService.js'; +import { IHostService } from '../../host/browser/host.js'; +import { ICodexAccountService, shouldShowCodexAccount } from './codexAccountService.js'; +import { CODEX_CONTINUATION_DEFAULT_THRESHOLD, CODEX_CONTINUATION_MAX_AGE, CodexContinuationSurface, getCodexContinuationCandidates, getCodexTriggeringLimits, ICodexContinuationCandidate, ICodexContinuationEpisode, updateCodexEpisode } from './codexContinuation.js'; + +export const CODEX_CONTINUATION_SETTING = 'chat.experimental.codexContinuation.enabled'; +export const CODEX_CONTINUATION_THRESHOLD_SETTING = 'chat.experimental.codexContinuation.thresholdPercent'; +export const CODEX_CONTINUATION_STORAGE_KEY = 'agentHost.codexContinuation'; +export type CodexContinuationAction = 'shown' | 'continueClicked' | 'dismissed' | 'dontShowAgain' | 'guideShown' | 'guideCompleted' | 'guideCancelled' | 'guideUnavailable'; +interface IStoredContinuation { + readonly permanent?: 'disabled' | 'completed'; + readonly reservation?: { readonly owner: string; readonly until: number }; + readonly episode?: ICodexContinuationEpisode; +} +export const ICodexContinuationService = createDecorator('codexContinuationService'); +export interface ICodexContinuationService { + readonly _serviceBrand: undefined; + readonly candidate: IObservable; + readonly revision: IObservable; + /** Whether this window is showing a claimed notice or its guide. */ + readonly isVisible: IObservable; + /** Scopes contextual accessibility help to an actually visible surface. */ + trackVisibility(): IDisposable; + setActiveSession(resource: URI | undefined): void; + setSelectableModels(models: readonly { readonly id: string; readonly vendor: string }[]): void; + refresh(): void; + /** Previews the real flow without quota/treatment gating, shared suppression writes, or telemetry. */ + showPreview(): Promise; + /** Ends a developer preview when its notice or guided action is closed. */ + endPreview(): void; + resolve(candidate?: ICodexContinuationCandidate, activeSession?: string, allowCommittedTarget?: boolean): Promise; + wouldShow(surface: CodexContinuationSurface): Promise; + reservePresentation(): Promise; + releasePresentation(): Promise; + markVisible(surface: CodexContinuationSurface, candidate: ICodexContinuationCandidate, isVisible?: () => boolean): Promise; + ownsEpisode(): boolean; + dismiss(surface: CodexContinuationSurface): void; + disable(surface: CodexContinuationSurface): Promise; + complete(surface: CodexContinuationSurface): Promise; + log(action: CodexContinuationAction, surface: CodexContinuationSurface): void; +} + +type InteractionEvent = { action: CodexContinuationAction; surface: CodexContinuationSurface; limitKind: 'fiveHour' | 'weekly' | 'both' }; +type InteractionClassification = { + owner: 'Giuspepe'; + comment: 'Treatment-only ChatGPT to Copilot continuation funnel. Eligibility uses the shared experiment trigger.'; + action: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bounded nudge or guide interaction.' }; + surface: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The window surface presenting the nudge.' }; + limitKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Supported ChatGPT window that triggered the suggestion: fiveHour, weekly, or both.' }; +}; + +export class CodexContinuationService extends Disposable implements ICodexContinuationService { + declare readonly _serviceBrand: undefined; + private readonly _candidate = observableValue(this, undefined); + readonly candidate = this._candidate; + private readonly _revision = observableValue(this, 0); + readonly revision = this._revision; + private readonly _visibleSurfaces = observableValue(this, 0); + readonly isVisible = derived(this, reader => this._visibleSurfaces.read(reader) > 0); + private readonly _owner = generateUuid(); + private readonly _evaluate = this._register(new RunOnceScheduler(() => { void this._update().catch(onUnexpectedError); }, 100)); + private readonly _expiry = this._register(new RunOnceScheduler(() => this._evaluate.schedule(), 0)); + private _generation = 0; + private _lastRefresh = 0; + private _triggered = false; + private _activeSession: string | undefined; + private _state: IStoredContinuation = {}; + private _preview: { state: IStoredContinuation } | undefined; + private readonly _writes = new Sequencer(); + private _selectableModels: readonly { readonly id: string; readonly vendor: string }[] = []; + + constructor( + @IAgentHostService private readonly _agentHost: IAgentHostService, + @IAgentHostConnectionsService private readonly _connections: IAgentHostConnectionsService, + @ICodexAccountService private readonly _account: ICodexAccountService, + @IChatEntitlementService private readonly _entitlement: IChatEntitlementService, + @IHostService private readonly _host: IHostService, + @IStorageService private readonly _storage: IStorageService, + @ITelemetryService private readonly _telemetry: ITelemetryService, + @IConfigurationService private readonly _configuration: IConfigurationService, + @IWorkbenchEnvironmentService private readonly _environment: IWorkbenchEnvironmentService, + ) { + super(); + const schedule = () => { this._generation++; this._evaluate.schedule(); }; + this._register(_account.onDidChangeAccount(schedule)); + this._register(_entitlement.onDidChangeEntitlement(schedule)); + this._register(_entitlement.onDidChangeQuotaRemaining(schedule)); + this._register(_entitlement.onDidChangeQuotaExceeded(schedule)); + this._register(_entitlement.onDidChangeSentiment(schedule)); + this._register(_configuration.onDidChangeConfiguration(() => { bind(); this.refresh(); })); + this._register(_host.onDidChangeFocus(focused => { if (focused) { this.refresh(); } schedule(); })); + this._register(_storage.onDidChangeValue(StorageScope.APPLICATION_SHARED, CODEX_CONTINUATION_STORAGE_KEY, this._store)(() => { + if (this._triggered) { + this._state = this._parse(this._storage.get(CODEX_CONTINUATION_STORAGE_KEY, StorageScope.APPLICATION_SHARED)); + } + this._changed(); + this._evaluate.schedule(); + })); + const root = this._register(new DisposableStore()); + const bind = () => { + root.clear(); + if (this._enabled()) { + root.add(_agentHost.rootState.onDidChange(schedule)); + root.add(_agentHost.onDidNotification(schedule)); + } + schedule(); + }; + this._register(_agentHost.onAgentHostStart(bind)); + bind(); + this.refresh(); + } + + trackVisibility(): IDisposable { + this._visibleSurfaces.set(this._visibleSurfaces.get() + 1, undefined); + return toDisposable(() => this._visibleSurfaces.set(this._visibleSurfaces.get() - 1, undefined)); + } + + setActiveSession(resource: URI | undefined): void { + const resolution = resource && this._connections.resolveSessionResource(resource); + const active = resolution?.connection === this._connections.ambientConnection ? resolution.backendSession.toString() : undefined; + if (active !== this._activeSession) { this._activeSession = active; this._generation++; this._evaluate.schedule(); } + } + + setSelectableModels(models: readonly { readonly id: string; readonly vendor: string }[]): void { + this._selectableModels = models; + this._generation++; + this._evaluate.schedule(); + } + + refresh(): void { + if (!this._host.hasFocus || !this._enabled() || Date.now() - this._lastRefresh < CODEX_CONTINUATION_MAX_AGE) { + return; + } + this._lastRefresh = Date.now(); + this._agentHost.dispatch(ROOT_STATE_URI, { type: ActionType.RootConfigChanged, config: { [CODEX_ACCOUNT_REFRESH_REQUEST_KEY]: generateUuid() } }); + } + + async showPreview(): Promise { + if (!this._host.hasFocus || this._store.isDisposed) { return false; } + // Let a real presentation finish its pending storage claim before deciding + // whether there is already a notice or guide to show. + await this._writes.queue(async () => { }); + if (this._preview || this.isVisible.get() || (this._state.reservation?.owner === this._owner && this._state.reservation.until > Date.now())) { return true; } + this._preview = { state: {} }; + this._generation++; + await this._update(); + if (!this._candidate.get()) { + this.endPreview(); + return false; + } + return true; + } + + endPreview(): void { + if (this._preview) { + this._preview = undefined; + this._generation++; + this._changed(); + this._evaluate.schedule(); + } + } + + private _presentationEnabled(): boolean { + return !!this._preview || this._configuration.getValue(CODEX_CONTINUATION_SETTING) === true; + } + + private _thresholdPercent(): number { + const value = this._configuration.getValue(CODEX_CONTINUATION_THRESHOLD_SETTING); + return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 100 ? value : CODEX_CONTINUATION_DEFAULT_THRESHOLD; + } + + private _enabled(): boolean { + return shouldShowCodexAccount(this._configuration, this._environment.isSessionsWindow) + && !this._entitlement.sentiment.hidden && !this._entitlement.sentiment.disabledInWorkspace; + } + + private _baseEligible(): boolean { + return this._enabled() && (!!this._preview || getCodexTriggeringLimits(this._account.account, Date.now(), this._thresholdPercent()).length > 0) + && hasUsableCopilotPremiumQuota(this._entitlement.entitlement, this._entitlement.quotas) + && [this._entitlement.quotas.sessionRateLimit, this._entitlement.quotas.weeklyRateLimit].every(limit => + !limit || limit.unlimited || (Number.isFinite(limit.percentRemaining) && limit.percentRemaining > 10 && limit.percentRemaining <= 100)); + } + + async resolve(expected?: ICodexContinuationCandidate, activeSession?: string, allowCommittedTarget?: boolean): Promise { + if (!this._baseEligible() || this._store.isDisposed) { + return undefined; + } + let sessions: IAgentSessionMetadata[]; + try { sessions = await this._agentHost.listSessions(); } catch { return undefined; } + if (!this._baseEligible() || this._store.isDisposed) { + return undefined; + } + const root = this._agentHost.rootState.value; + const models = root && !(root instanceof Error) ? root.agents.find(agent => agent.provider === 'codex')?.models ?? [] : []; + // A guided draft may already be committed to the exact target. Preserve the + // original source only for that exact session/pair during confirmation. + if (allowCommittedTarget && expected) { + sessions = sessions.map(session => session.session.toString() === expected.session.session.toString() && session.model?.id === expected.target.id + ? { ...session, model: { id: expected.source.id } } : session); + } + const candidates = getCodexContinuationCandidates(sessions, models, activeSession ?? this._activeSession).filter(candidate => { + if (candidate.session.provider) { + this._connections.registerSessionResource(candidate.session.session, undefined, candidate.session.provider); + } + const resource = this._connections.getSessionResource(candidate.session.session); + return this._selectableModels.some(model => model.id === candidate.target.id && model.vendor === resource?.scheme); + }); + return expected ? candidates.find(candidate => candidate.session.session.toString() === expected.session.session.toString() + && candidate.source.id === expected.source.id && candidate.target.id === expected.target.id) : candidates[0]; + } + + private async _update(): Promise { + const generation = this._generation; + const candidate = await this.resolve(); + if (this._store.isDisposed || generation !== this._generation) { return; } + this._candidate.set(candidate, undefined); + const now = Date.now(); + const deadlines = [...getCodexTriggeringLimits(this._account.account, now, this._thresholdPercent()), ...(this._triggered ? this._state.episode?.limits ?? [] : [])].map(limit => limit.until); + if (this._state.reservation) { deadlines.push(this._state.reservation.until); } + if (this._account.account.observedAt !== undefined) { deadlines.push(this._account.account.observedAt + CODEX_CONTINUATION_MAX_AGE + 1); } + const next = deadlines.filter(deadline => deadline > now).sort((a, b) => a - b)[0]; + this._expiry.cancel(); + if (next !== undefined) { this._expiry.schedule(Math.min(next - now, 2147483647)); } + if (this._triggered) { await this._mutate(state => state); } + this._changed(); + } + + /** Caller establishes focus and surface presentability identically in both arms. */ + async wouldShow(_surface: CodexContinuationSurface): Promise { + if (!this._host.hasFocus || !this._candidate.get() || !this._baseEligible() || !await this._host.hadLastFocus()) { return false; } + if (!this._host.hasFocus || !this._baseEligible() || this._store.isDisposed) { return false; } + if (!this._preview) { + logSettingExperimentTrigger(this._telemetry, CODEX_CONTINUATION_SETTING); + this._triggered = true; + } + if (!this._presentationEnabled()) { return false; } + if (!this._host.hasFocus || !this._baseEligible() || this._store.isDisposed) { return false; } + const state = await this._mutate(state => state); + return this._presentationEnabled() && !state.permanent && !state.episode && !state.reservation; + } + + async reservePresentation(): Promise { + if (!this._presentationEnabled() || !this._host.hasFocus || !this._baseEligible() || this._store.isDisposed || !await this._host.hadLastFocus()) { return false; } + const state = await this._mutate(state => !this._presentationEnabled() || !this._host.hasFocus || !this._baseEligible() || this._store.isDisposed || state.permanent || state.episode || state.reservation ? state : { + ...state, reservation: { owner: this._owner, until: Date.now() + 30_000 }, + }); + return state.reservation?.owner === this._owner; + } + + async releasePresentation(): Promise { + await this._mutate(state => state.reservation?.owner === this._owner ? { ...state, reservation: undefined } : state).catch(onUnexpectedError); + } + + async markVisible(surface: CodexContinuationSurface, candidate: ICodexContinuationCandidate, isVisible: () => boolean = () => true): Promise { + const canClaim = () => (!!this._preview || this._triggered) && this._presentationEnabled() && !this._store.isDisposed && this._host.hasFocus && isVisible() && this._baseEligible() + && this._candidate.get()?.session.session.toString() === candidate.session.session.toString(); + if (!canClaim() || !await this._host.hadLastFocus()) { return false; } + let claimed = false; + const state = await this._mutate(state => { + claimed = canClaim() && state.reservation?.owner === this._owner && !state.permanent && !state.episode; + return !claimed ? state : { + episode: { owner: this._owner, surface, limits: getCodexTriggeringLimits(this._account.account, Date.now(), this._thresholdPercent()) }, + }; + }); + const shown = claimed && state.episode?.owner === this._owner; + if (shown) { this.log('shown', surface); } + return shown; + } + + ownsEpisode(): boolean { + const state = this._preview?.state ?? this._state; + return (!!this._preview || this._triggered) && this._presentationEnabled() && !state.permanent && state.episode?.owner === this._owner; + } + + dismiss(surface: CodexContinuationSurface): void { this.log('dismissed', surface); this._changed(); } + async disable(surface: CodexContinuationSurface): Promise { + await this._mutate(state => ({ ...state, permanent: 'disabled', reservation: undefined })); + this.log('dontShowAgain', surface); + } + async complete(surface: CodexContinuationSurface): Promise { + await this._mutate(state => ({ ...state, permanent: 'completed', reservation: undefined })); + this.log('guideCompleted', surface); + } + + private _parse(raw: string | undefined): IStoredContinuation { + try { + const value: IStoredContinuation = raw ? JSON.parse(raw) : {}; + if (!value || typeof value !== 'object' + || (value.permanent !== undefined && value.permanent !== 'disabled' && value.permanent !== 'completed') + || (value.reservation !== undefined && (!value.reservation || typeof value.reservation.owner !== 'string' || !Number.isFinite(value.reservation.until))) + || (value.episode !== undefined && (!value.episode || typeof value.episode.owner !== 'string' + || (value.episode.surface !== 'agentsWindow' && value.episode.surface !== 'editorWindow') + || !Array.isArray(value.episode.limits) || value.episode.limits.length > 2 + || !value.episode.limits.every(limit => limit && (limit.duration === 300 || limit.duration === 10080) + && Number.isFinite(limit.until) && Number.isFinite(limit.observedAt) && typeof limit.reliable === 'boolean' + && (limit.thresholdPercent === undefined || (Number.isFinite(limit.thresholdPercent) && limit.thresholdPercent >= 0 && limit.thresholdPercent <= 100)))))) { + return {}; + } + return { permanent: value.permanent, reservation: value.reservation, episode: value.episode }; + } catch { return {}; } + } + + /** Compare-and-swap uses the authoritative shared database, never a stale renderer cache. */ + private _mutate(change: (state: IStoredContinuation) => IStoredContinuation): Promise { + const preview = this._preview; + return this._writes.queue(async () => { + // Capture the preview before queueing: a late release must never write + // real suppression after the preview has ended. + if (preview) { return preview.state = change(preview.state); } + let raw = await this._storage.readApplicationSharedValue(CODEX_CONTINUATION_STORAGE_KEY); + for (let attempt = 0; attempt < 10; attempt++) { + const state = this._parse(raw); + const next = change({ + ...state, + episode: updateCodexEpisode(state.episode, this._account.account, Date.now(), this._thresholdPercent()), + reservation: state.reservation && state.reservation.until > Date.now() ? state.reservation : undefined, + }); + const value = JSON.stringify(next); + if (value === (raw ?? '{}')) { this._state = next; return next; } + const result = await this._storage.compareAndSwapApplicationSharedValue(CODEX_CONTINUATION_STORAGE_KEY, raw, value); + if (result.swapped) { this._state = next; return next; } + raw = result.currentValue; + } + // Fail closed under contention; the next observed storage change retries. + return { permanent: 'disabled' }; + }); + } + private _changed(): void { this._revision.set(this._revision.get() + 1, undefined); } + + log(action: CodexContinuationAction, surface: CodexContinuationSurface): void { + if (this._preview) { return; } + const limits = getCodexTriggeringLimits(this._account.account, Date.now(), this._thresholdPercent()); + this._telemetry.publicLog2('agentHost.codexContinuation', { + action, surface, limitKind: limits.length > 1 ? 'both' : limits[0]?.duration === 300 ? 'fiveHour' : 'weekly', + }); + } +} +registerSingleton(ICodexContinuationService, CodexContinuationService, InstantiationType.Delayed); diff --git a/src/vs/workbench/services/agentHost/test/browser/codexContinuation.test.ts b/src/vs/workbench/services/agentHost/test/browser/codexContinuation.test.ts new file mode 100644 index 00000000000000..f2ea2efc15da03 --- /dev/null +++ b/src/vs/workbench/services/agentHost/test/browser/codexContinuation.test.ts @@ -0,0 +1,136 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { AgentSession, IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agent.js'; +import { ICodexAccountInfo } from '../../../../../platform/agentHost/common/codexAccount.js'; +import { toCodexModelProvider } from '../../../../../platform/agentHost/common/codexModelSelection.js'; +import { PolicyState, SessionModelInfo, SessionStatus } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { ChatEntitlement, hasUsableCopilotPremiumQuota } from '../../../chat/common/chatEntitlementService.js'; +import { CODEX_CONTINUATION_MAX_AGE, getCodexContinuationCandidates, getCodexTriggeringLimits, ICodexContinuationEpisode, updateCodexEpisode } from '../../browser/codexContinuation.js'; + +suite('Codex continuation eligibility', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + const now = 1_000_000_000; + const account = (overrides: Partial = {}): ICodexAccountInfo => ({ status: 'signedIn', planType: 'plus', observedAt: now, rateLimits: [{ usedPercent: 90, windowDurationMins: 300, resetsAt: now / 1000 + 60 }], ...overrides }); + + for (const plan of ['go', 'plus', 'pro', 'prolite', 'team', 'business', 'enterprise', 'edu', 'edu_plus', 'self_serve_business_usage_based']) { + test(`accepts paid ChatGPT plan ${plan}`, () => assert.strictEqual(getCodexTriggeringLimits(account({ planType: plan }), now).length, 1)); + } + for (const plan of ['free', 'unknown', 'future-plan', undefined]) { + test(`rejects unresolved or free ChatGPT plan ${plan}`, () => assert.deepStrictEqual(getCodexTriggeringLimits(account({ planType: plan }), now), [])); + } + test('requires fresh observations, supported durations, threshold, and a future reset', () => { + const cases: Partial[] = [ + { status: 'signedOut' }, { observedAt: undefined }, { observedAt: now + 1 }, { observedAt: now - CODEX_CONTINUATION_MAX_AGE - 1 }, + ...[89.99, NaN, 101].map(usedPercent => ({ rateLimits: [{ usedPercent, windowDurationMins: 300 }] })), + { rateLimits: [{ usedPercent: 95, windowDurationMins: 60 }] }, + { rateLimits: [{ usedPercent: 95, windowDurationMins: 300, resetsAt: now / 1000 }] }, + ]; + assert.deepStrictEqual(cases.map(value => getCodexTriggeringLimits(account(value), now)), cases.map(() => [])); + assert.strictEqual(getCodexTriggeringLimits(account({ observedAt: now - CODEX_CONTINUATION_MAX_AGE }), now).length, 1); + }); + test('overlapping limits extend suppression until the last reset', () => { + const episode: ICodexContinuationEpisode = { owner: 'one', surface: 'editorWindow', limits: getCodexTriggeringLimits(account(), now) }; + const both = account({ rateLimits: [...account().rateLimits!, { usedPercent: 95, windowDurationMins: 10080, resetsAt: now / 1000 + 180 }] }); + const extended = updateCodexEpisode(episode, both, now)!; + assert.deepStrictEqual([extended.limits.length, updateCodexEpisode(extended, both, now + 60_000)?.limits.length, updateCodexEpisode(extended, both, now + 180_000)], [2, 1, undefined]); + }); + for (const windowDurationMins of [300, 10080]) { + test(`configurable thresholds are inclusive for the ${windowDurationMins}-minute limit`, () => { + const cases = [ + { threshold: 0, usage: -1, eligible: false }, { threshold: 0, usage: 0, eligible: true }, + { threshold: 80, usage: 79.99, eligible: false }, { threshold: 80, usage: 80, eligible: true }, { threshold: 80, usage: 85, eligible: true }, + { threshold: 90, usage: 89.99, eligible: false }, { threshold: 90, usage: 90, eligible: true }, + { threshold: 95.5, usage: 95.49, eligible: false }, { threshold: 95.5, usage: 95.5, eligible: true }, + { threshold: 100, usage: 99.99, eligible: false }, { threshold: 100, usage: 100, eligible: true }, { threshold: 100, usage: 101, eligible: false }, + ]; + assert.deepStrictEqual(cases.map(({ threshold, usage }) => getCodexTriggeringLimits(account({ + rateLimits: [{ usedPercent: usage, windowDurationMins }], + }), now, threshold).length > 0), cases.map(value => value.eligible)); + }); + } + test('a custom threshold extends suppression to another qualifying usage window', () => { + const initial = account({ rateLimits: [{ usedPercent: 80, windowDurationMins: 300, resetsAt: now / 1000 + 60 }] }); + const episode: ICodexContinuationEpisode = { owner: 'one', surface: 'editorWindow', limits: getCodexTriggeringLimits(initial, now, 80) }; + const both = { ...initial, rateLimits: [...initial.rateLimits!, { usedPercent: 85, windowDurationMins: 10080, resetsAt: now / 1000 + 180 }] }; + assert.deepStrictEqual(updateCodexEpisode(episode, both, now, 80)?.limits.map(limit => limit.duration), [300, 10080]); + }); + test('unknown-reset suppression clears below the configured threshold, not the default', () => { + const initial = account({ rateLimits: [{ usedPercent: 80, windowDurationMins: 300 }] }); + const episode: ICodexContinuationEpisode = { owner: 'one', surface: 'agentsWindow', limits: getCodexTriggeringLimits(initial, now, 80) }; + assert.deepStrictEqual([79.99, 80, 85].map(usedPercent => updateCodexEpisode(episode, { + ...initial, observedAt: now + 1000, rateLimits: [{ usedPercent, windowDurationMins: 300 }], + }, now + 1000, 80)?.limits[0].until), [undefined, now + 300 * 60_000, now + 300 * 60_000]); + }); + test('legacy episodes retain their original 90-percent recovery boundary', () => { + const episode: ICodexContinuationEpisode = { + owner: 'one', surface: 'agentsWindow', + limits: [{ duration: 300, until: now + 300 * 60_000, observedAt: now, reliable: false }], + }; + const fresh = account({ observedAt: now + 1000, rateLimits: [{ usedPercent: 85, windowDurationMins: 300 }] }); + assert.strictEqual(updateCodexEpisode(episode, fresh, now + 1000, 80), undefined); + }); + test('unknown resets do not slide and clear only on newer fresh below-threshold data', () => { + const unknown = account({ rateLimits: [{ usedPercent: 95, windowDurationMins: 300 }] }); + const episode: ICodexContinuationEpisode = { owner: 'one', surface: 'agentsWindow', limits: getCodexTriggeringLimits(unknown, now) }; + const below = { ...unknown, observedAt: now + 1000, rateLimits: [{ usedPercent: 10, windowDurationMins: 300 }] }; + assert.deepStrictEqual([ + updateCodexEpisode(episode, { ...unknown, observedAt: now + 1000 }, now + 1000)?.limits[0].until, + updateCodexEpisode(episode, below, now + 1000), + updateCodexEpisode(episode, below, now + CODEX_CONTINUATION_MAX_AGE + 1001)?.limits.length, + updateCodexEpisode(episode, unknown, now + 300 * 60_000), + ], [now + 300 * 60_000, undefined, 1, undefined]); + }); + test('individual overage is eligible at zero credits; managed blocks and missing allowances fail closed', () => { + const zero = { premiumChat: { unlimited: false, percentRemaining: 0, hasQuota: false } }; + assert.deepStrictEqual([ + hasUsableCopilotPremiumQuota(ChatEntitlement.Pro, zero), + hasUsableCopilotPremiumQuota(ChatEntitlement.Pro, { ...zero, additionalUsageEnabled: true }), + hasUsableCopilotPremiumQuota(ChatEntitlement.Business, { ...zero, additionalUsageEnabled: true }), + hasUsableCopilotPremiumQuota(ChatEntitlement.Enterprise, { premiumChat: { unlimited: true, percentRemaining: 100, hasQuota: false } }), + hasUsableCopilotPremiumQuota(ChatEntitlement.Free, { premiumChat: { unlimited: false, percentRemaining: 100 } }), + hasUsableCopilotPremiumQuota(ChatEntitlement.Pro, {}), + hasUsableCopilotPremiumQuota(ChatEntitlement.Pro, { premiumChat: { unlimited: false, percentRemaining: NaN } }), + ], [false, true, false, false, false, false, false]); + }); + test('exact qualified pairing requires subscription metadata and prefers active then recency', () => { + const source: SessionModelInfo = { provider: 'codex', id: '@provider=openai:gpt', name: 'Source name', _meta: { modelSourceId: 'chatgptSubscription' } }; + const target: SessionModelInfo = { provider: 'codex', id: '@provider=vscode-proxy:gpt', name: 'Different target name' }; + const session = (id: string, modifiedTime: number, model = source.id): IAgentSessionMetadata => ({ session: AgentSession.uri('codex', id), startTime: 0, modifiedTime, model: { id: model } }); + const sessions = [session('old', 1), session('new', 2), session('bare', 3, 'gpt'), session('custom', 4, '@provider=custom:gpt')]; + assert.deepStrictEqual([ + getCodexContinuationCandidates(sessions, [source, target]).map(candidate => AgentSession.id(candidate.session.session)), + getCodexContinuationCandidates(sessions, [source, target], sessions[0].session.toString()).map(candidate => AgentSession.id(candidate.session.session)), + getCodexContinuationCandidates(sessions, [{ ...source, _meta: undefined }, target]), + getCodexContinuationCandidates(sessions, [source, { ...target, policyState: PolicyState.Disabled }]), + getCodexContinuationCandidates(sessions, [source, { ...target, id: '@provider=vscode-proxy:other', name: source.name }]), + ], [['new', 'old'], ['old', 'new'], [], [], []]); + }); + test('provider metadata selects Codex candidates without inferring a standard or opaque URI scheme', () => { + const source: SessionModelInfo = { provider: 'codex', id: '@provider=openai:gpt', name: 'GPT', _meta: { modelSourceId: 'chatgptSubscription' } }; + const target: SessionModelInfo = { provider: 'codex', id: '@provider=vscode-proxy:gpt', name: 'GPT Copilot' }; + const resources = ['codex:/legacy', 'ahp-session:/standard', 'session-store://tenant/opaque?revision=2']; + const sessions: IAgentSessionMetadata[] = resources.map(resource => ({ session: URI.parse(resource), provider: 'codex', startTime: 0, modifiedTime: 0, model: { id: source.id } })); + const other = { ...sessions[1], provider: 'claude' }; + const missing = { ...sessions[1], provider: undefined }; + assert.deepStrictEqual(getCodexContinuationCandidates([...sessions, other, missing], [source, target]).map(candidate => candidate.session.session.toString()), sessions.map(session => session.session.toString())); + }); + test('archived active sessions do not outrank newer candidates', () => { + const source: SessionModelInfo = { provider: 'codex', id: '@provider=openai:gpt', name: 'GPT', _meta: { modelSourceId: 'chatgptSubscription' } }; + const target: SessionModelInfo = { provider: 'codex', id: '@provider=vscode-proxy:gpt', name: 'GPT Copilot' }; + const archived: IAgentSessionMetadata = { session: AgentSession.uri('codex', 'archived'), startTime: 0, modifiedTime: 1, model: { id: source.id }, status: SessionStatus.IsArchived }; + const external: IAgentSessionMetadata = { session: AgentSession.uri('codex', 'external'), startTime: 0, modifiedTime: 2, model: { id: source.id } }; + assert.deepStrictEqual( + getCodexContinuationCandidates([archived, external], [source, target], archived.session.toString()).map(candidate => AgentSession.id(candidate.session.session)), + ['external'], + ); + }); + test('turn provider is a bounded dispatch fact', () => { + assert.deepStrictEqual(['openai', 'vscode-proxy', 'private-provider', '', undefined].map(toCodexModelProvider), ['openai', 'copilot', 'other', 'unknown', 'unknown']); + }); +}); diff --git a/src/vs/workbench/services/agentHost/test/browser/codexContinuationService.test.ts b/src/vs/workbench/services/agentHost/test/browser/codexContinuationService.test.ts new file mode 100644 index 00000000000000..250150ede96f64 --- /dev/null +++ b/src/vs/workbench/services/agentHost/test/browser/codexContinuationService.test.ts @@ -0,0 +1,447 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { timeout } from '../../../../../base/common/async.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +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 { NullAgentHostService } from '../../../../../platform/agentHost/browser/nullAgentHostService.js'; +import { AgentSession } from '../../../../../platform/agentHost/common/agent.js'; +import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { ICodexAccountInfo } from '../../../../../platform/agentHost/common/codexAccount.js'; +import { IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js'; +import { INotification, NotificationType } from '../../../../../platform/agentHost/common/state/sessionActions.js'; +import { ROOT_STATE_URI, RootState, SessionModelInfo } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { IConfigurationChangeEvent } from '../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { ITelemetryData } from '../../../../../platform/telemetry/common/telemetry.js'; +import { TestExperimentTriggerTelemetryService } from '../../../../../platform/telemetry/test/common/experimentTriggerTestUtils.js'; +import { ChatEntitlement, IChatEntitlementService } from '../../../chat/common/chatEntitlementService.js'; +import { IWorkbenchEnvironmentService } from '../../../environment/common/environmentService.js'; +import { IHostService } from '../../../host/browser/host.js'; +import { ICodexAccountService } from '../../browser/codexAccountService.js'; +import { CODEX_CONTINUATION_SETTING, CODEX_CONTINUATION_STORAGE_KEY, CODEX_CONTINUATION_THRESHOLD_SETTING, CodexContinuationService } from '../../browser/codexContinuationService.js'; + +suite('Codex continuation coordination', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + const source: SessionModelInfo = { provider: 'codex', id: '@provider=openai:gpt', name: 'GPT', _meta: { modelSourceId: 'chatgptSubscription' } }; + const target: SessionModelInfo = { provider: 'codex', id: '@provider=vscode-proxy:gpt', name: 'GPT Copilot' }; + + function create(storage: InMemoryStorageService, treatment = true, canMapSession = true) { + const order: string[] = []; + const activity = { listSessions: 0 }; + const backend = AgentSession.uri('codex', 'existing'); + const session: { session: URI; provider?: string; startTime: number; modifiedTime: number; model?: { id: string } } = { session: backend, startTime: 1, modifiedTime: 2, model: { id: source.id } }; + const registered = new Map(); + const notifications = store.add(new Emitter()); + const root = new class extends mock>() { + override onDidChange = Event.None; + override value = new class extends mock() { override agents = [{ provider: 'codex', displayName: 'Codex', description: '', models: [source, target] }]; }(); + }(); + const agent = new class extends NullAgentHostService { + override get rootState() { return root; } + override readonly onDidNotification = notifications.event; + override dispatch(): void { } + override async listSessions() { activity.listSessions++; return [session]; } + }(); + const connections = new class extends mock() { + override ambientConnection = agent; + override getSessionResource(resource: URI): URI | undefined { + return canMapSession ? registered.get(resource.toString()) ?? (resource.scheme === 'codex' ? URI.parse('agent-host-codex:/existing') : undefined) : undefined; + } + override registerSessionResource(resource: URI, _authority?: string, provider?: string): URI { + assert.strictEqual(provider, 'codex'); + const frontend = resource.with({ scheme: `agent-host-${provider}` }); + registered.set(resource.toString(), frontend); + return frontend; + } + }(); + const accountChanged = store.add(new Emitter()); + const account = new class extends mock() { + override onDidChangeAccount = accountChanged.event; + override account: ICodexAccountInfo = { status: 'signedIn', planType: 'plus', observedAt: Date.now(), rateLimits: [{ usedPercent: 95, windowDurationMins: 300 }] }; + }(); + const quotaChanged = store.add(new Emitter()); + const entitlement = new class extends mock() { + override onDidChangeEntitlement = Event.None; + override onDidChangeQuotaRemaining = quotaChanged.event; + override onDidChangeQuotaExceeded = Event.None; + override onDidChangeSentiment = Event.None; + override entitlement = ChatEntitlement.Pro; + override sentiment = { installed: true }; + override quotas: IChatEntitlementService['quotas'] = { premiumChat: { unlimited: false, percentRemaining: 0 }, additionalUsageEnabled: true }; + }(); + const host = new class extends mock() { + override hasFocus = true; + override onDidChangeFocus = Event.None; + lastFocused = true; + override async hadLastFocus() { return this.lastFocused; } + }(); + const telemetry = new class extends TestExperimentTriggerTelemetryService { + override publicLog2(name?: string, data?: ITelemetryData): void { + super.publicLog2(name, data); + order.push(name === 'experimentTrigger' ? 'trigger' : String(data?.action)); + } + }(); + const config = new TestConfigurationService({ chat: { agentHost: { codexAgent: { enabled: true } }, experimental: { codexContinuation: { enabled: treatment } } } }); + const environment = new class extends mock() { + override isSessionsWindow = true; + }(); + + const service = store.add(new CodexContinuationService(agent, connections, account, entitlement, host, storage, telemetry, config, environment)); + service.setSelectableModels([{ id: target.id, vendor: 'agent-host-codex' }]); + return { service, order, telemetry, config, host, account, accountChanged, entitlement, session, activity, notifications, quotaChanged, registered }; + } + + for (const resource of ['ahp-session:/existing', 'session-store://tenant/opaque?revision=2']) { + test(`continuation registers the exact host-listed Codex identity ${resource}`, () => runWithFakedTimers({}, async () => { + const { service, session, registered } = create(store.add(new InMemoryStorageService())); + session.session = URI.parse(resource); + session.provider = 'codex'; + const candidate = await service.resolve(); + assert.deepStrictEqual({ + candidate: candidate?.session.session.toString(), + frontend: registered.get(session.session.toString())?.toString(), + }, { candidate: session.session.toString(), frontend: session.session.with({ scheme: 'agent-host-codex' }).toString() }); + })); + } + + test('changing the threshold setting re-evaluates an existing quota observation', () => runWithFakedTimers({}, async () => { + const { service, config, account, activity } = create(store.add(new InMemoryStorageService())); + account.account = { ...account.account, rateLimits: [{ usedPercent: 85, windowDurationMins: 300 }] }; + await timeout(101); + assert.deepStrictEqual({ candidate: service.candidate.get(), listSessions: activity.listSessions }, { candidate: undefined, listSessions: 0 }); + const eligible: boolean[] = []; + for (const threshold of [80, 90, 85]) { + await config.setUserConfiguration(CODEX_CONTINUATION_THRESHOLD_SETTING, threshold); + config.onDidChangeConfigurationEmitter.fire(upcastPartial({ affectsConfiguration: () => true })); + await timeout(101); + eligible.push(!!service.candidate.get() && await service.wouldShow('agentsWindow')); + } + assert.deepStrictEqual(eligible, [true, false, true]); + })); + + for (const threshold of [undefined, null, -1, 101, NaN, Infinity, '80']) { + test(`invalid or missing threshold falls back to 90 percent: ${String(threshold)}`, () => runWithFakedTimers({}, async () => { + const { service, config, account } = create(store.add(new InMemoryStorageService())); + await config.setUserConfiguration(CODEX_CONTINUATION_THRESHOLD_SETTING, threshold); + account.account = { ...account.account, rateLimits: [{ usedPercent: 89.99, windowDurationMins: 300 }] }; + const below = await service.resolve(); + account.account = { ...account.account, rateLimits: [{ usedPercent: 90, windowDurationMins: 300 }] }; + assert.deepStrictEqual({ below: !!below, atDefault: !!await service.resolve() }, { below: false, atDefault: true }); + })); + } + + test('fresh usage above a lowered threshold keeps an already-shown episode suppressed', () => runWithFakedTimers({}, async () => { + const { service, config, account, accountChanged } = create(store.add(new InMemoryStorageService())); + await config.setUserConfiguration(CODEX_CONTINUATION_THRESHOLD_SETTING, 80); + account.account = { ...account.account, rateLimits: [{ usedPercent: 85, windowDurationMins: 300 }] }; + await timeout(101); + await service.wouldShow('agentsWindow'); + await service.reservePresentation(); + assert.strictEqual(await service.markVisible('agentsWindow', service.candidate.get()!), true); + service.dismiss('agentsWindow'); + await service.releasePresentation(); + const outcomes: boolean[] = []; + for (const usedPercent of [85, 79, 85]) { + await timeout(1000); + account.account = { ...account.account, observedAt: Date.now(), rateLimits: [{ usedPercent, windowDurationMins: 300 }] }; + accountChanged.fire(account.account); + await timeout(101); + outcomes.push(await service.wouldShow('agentsWindow')); + } + assert.deepStrictEqual(outcomes, [false, false, true]); + })); + + test('a window with a higher threshold cannot clear another window\'s suppression', () => runWithFakedTimers({}, async () => { + const storage = store.add(new InMemoryStorageService()); + const lower = create(storage); + const higher = create(storage); + await lower.config.setUserConfiguration(CODEX_CONTINUATION_THRESHOLD_SETTING, 80); + lower.account.account = { ...lower.account.account, rateLimits: [{ usedPercent: 85, windowDurationMins: 300 }] }; + await timeout(101); + await higher.service.wouldShow('editorWindow'); + await lower.service.wouldShow('agentsWindow'); + await lower.service.reservePresentation(); + assert.strictEqual(await lower.service.markVisible('agentsWindow', lower.service.candidate.get()!), true); + lower.service.dismiss('agentsWindow'); + await lower.service.releasePresentation(); + await timeout(1000); + higher.account.account = { ...higher.account.account, observedAt: Date.now(), rateLimits: [{ usedPercent: 85, windowDurationMins: 300 }] }; + higher.accountChanged.fire(higher.account.account); + await timeout(101); + assert.deepStrictEqual({ + higher: await higher.service.wouldShow('editorWindow'), lower: await lower.service.wouldShow('agentsWindow'), + }, { higher: false, lower: false }); + })); + + for (const treatment of [false, true]) { + test(`does not suggest a session whose exact identity cannot be mapped in arm ${treatment}`, () => runWithFakedTimers({}, async () => { + const { service, order } = create(store.add(new InMemoryStorageService()), treatment, false); + await timeout(101); + assert.deepStrictEqual({ + candidate: service.candidate.get(), + wouldShow: await service.wouldShow('agentsWindow'), + telemetry: order, + }, { candidate: undefined, wouldShow: false, telemetry: [] }); + })); + + test(`trigger precedes setting and permanent-state gating in arm ${treatment}`, () => runWithFakedTimers({}, async () => { + const storage = store.add(new InMemoryStorageService()); + storage.store(CODEX_CONTINUATION_STORAGE_KEY, { permanent: 'completed' }, StorageScope.APPLICATION_SHARED, StorageTarget.MACHINE); + const { service, order, activity, telemetry } = create(storage, treatment); + await timeout(101); + assert.strictEqual(await service.wouldShow('agentsWindow'), false); + assert.deepStrictEqual({ order, listSessions: activity.listSessions, triggers: telemetry.triggers }, { + order: ['trigger'], listSessions: 1, triggers: [`config.${CODEX_CONTINUATION_SETTING}`], + }); + })); + + test(`the effective setting controls presentation in arm ${treatment}`, () => runWithFakedTimers({}, async () => { + const { service, telemetry } = create(store.add(new InMemoryStorageService()), treatment); + await timeout(101); + assert.deepStrictEqual({ + first: await service.wouldShow('agentsWindow'), second: await service.wouldShow('agentsWindow'), + reserved: await service.reservePresentation(), triggers: telemetry.triggers, + }, { + first: treatment, second: treatment, reserved: treatment, + triggers: [`config.${CODEX_CONTINUATION_SETTING}`], + }); + })); + } + + for (const action of ['dismiss', 'disable', 'complete'] as const) { + test(`developer preview bypasses quota and setting without changing suppression or telemetry: ${action}`, () => runWithFakedTimers({}, async () => { + let sharedReads = 0; + let sharedWrites = 0; + const storage = store.add(new class extends InMemoryStorageService { + override async readApplicationSharedValue(key: string): Promise { + sharedReads++; + return super.readApplicationSharedValue(key); + } + override async compareAndSwapApplicationSharedValue(key: string, expected: string | undefined, value: string) { + sharedWrites++; + return super.compareAndSwapApplicationSharedValue(key, expected, value); + } + }()); + storage.store(CODEX_CONTINUATION_STORAGE_KEY, { permanent: 'disabled' }, StorageScope.APPLICATION_SHARED, StorageTarget.MACHINE); + const persisted = storage.get(CODEX_CONTINUATION_STORAGE_KEY, StorageScope.APPLICATION_SHARED); + const { service, account, order } = create(storage, false); + account.account = { ...account.account, rateLimits: [{ usedPercent: 1, windowDurationMins: 300 }] }; + await timeout(101); + assert.strictEqual(await service.showPreview(), true); + assert.strictEqual(await service.wouldShow('agentsWindow'), true); + assert.strictEqual(await service.reservePresentation(), true); + assert.strictEqual(await service.markVisible('agentsWindow', service.candidate.get()!), true); + assert.strictEqual(service.ownsEpisode(), true); + await service[action]('agentsWindow'); + const release = service.releasePresentation(); + service.endPreview(); + await release; + await timeout(101); + assert.deepStrictEqual({ + persisted: storage.get(CODEX_CONTINUATION_STORAGE_KEY, StorageScope.APPLICATION_SHARED), + sharedReads, sharedWrites, telemetry: order, ownsEpisode: service.ownsEpisode(), candidate: service.candidate.get(), + }, { persisted, sharedReads: 0, sharedWrites: 0, telemetry: [], ownsEpisode: false, candidate: undefined }); + assert.strictEqual(await service.showPreview(), true); + service.endPreview(); + })); + } + + test('developer preview retains model, identity, and Copilot quota checks', () => runWithFakedTimers({}, async () => { + const unmappable = create(store.add(new InMemoryStorageService()), false, false); + const missingModel = create(store.add(new InMemoryStorageService()), false); + delete missingModel.session.model; + const exhausted = create(store.add(new InMemoryStorageService()), false); + exhausted.entitlement.quotas = { premiumChat: { unlimited: false, percentRemaining: 0 } }; + assert.deepStrictEqual(await Promise.all([unmappable, missingModel, exhausted].map(async h => ({ + preview: await h.service.showPreview(), candidate: h.service.candidate.get(), telemetry: h.order, + }))), Array.from({ length: 3 }, () => ({ preview: false, candidate: undefined, telemetry: [] }))); + })); + + test('turning off the setting invalidates a claimed notice and prevents further claims', () => runWithFakedTimers({}, async () => { + const { service, config, telemetry } = create(store.add(new InMemoryStorageService())); + await timeout(101); + await service.wouldShow('agentsWindow'); + await service.reservePresentation(); + const candidate = service.candidate.get()!; + assert.strictEqual(await service.markVisible('agentsWindow', candidate), true); + await config.setUserConfiguration(CODEX_CONTINUATION_SETTING, false); + assert.deepStrictEqual({ + ownsEpisode: service.ownsEpisode(), wouldShow: await service.wouldShow('agentsWindow'), + reserved: await service.reservePresentation(), visible: await service.markVisible('agentsWindow', candidate), + triggers: telemetry.triggers, + }, { ownsEpisode: false, wouldShow: false, reserved: false, visible: false, triggers: [`config.${CODEX_CONTINUATION_SETTING}`] }); + })); + + test('accessibility visibility is scoped to live notice and guide lifetimes', () => { + const { service } = create(store.add(new InMemoryStorageService())); + const visibility = [service.isVisible.get()]; + const notice = store.add(service.trackVisibility()); + visibility.push(service.isVisible.get()); + const guide = store.add(service.trackVisibility()); + notice.dispose(); + visibility.push(service.isVisible.get()); + guide.dispose(); + visibility.push(service.isVisible.get()); + assert.deepStrictEqual(visibility, [false, true, true, false]); + }); + test('low ChatGPT usage and Copilot Free never enumerate session metadata', () => runWithFakedTimers({}, async () => { + const lowUsage = create(store.add(new InMemoryStorageService())); + lowUsage.account.account = { ...lowUsage.account.account, rateLimits: [{ usedPercent: 1, windowDurationMins: 300 }] }; + const free = create(store.add(new InMemoryStorageService())); + free.entitlement.entitlement = ChatEntitlement.Free; + + await timeout(101); + + assert.deepStrictEqual({ + lowUsage: { candidate: lowUsage.service.candidate.get(), listSessions: lowUsage.activity.listSessions }, + free: { candidate: free.service.candidate.get(), listSessions: free.activity.listSessions }, + }, { + lowUsage: { candidate: undefined, listSessions: 0 }, + free: { candidate: undefined, listSessions: 0 }, + }); + })); + test('an ordinary live-session notification re-evaluates a newly published model without discovery', () => runWithFakedTimers({}, async () => { + const { service, session, activity, notifications } = create(store.add(new InMemoryStorageService())); + delete session.model; + await timeout(101); + assert.strictEqual(service.candidate.get(), undefined); + + session.model = { id: source.id }; + notifications.fire({ + type: NotificationType.SessionSummaryChanged, + channel: ROOT_STATE_URI, + session: session.session.toString(), + changes: { _meta: {} }, + }); + await timeout(101); + + assert.deepStrictEqual({ + candidate: service.candidate.get()?.session.session.toString(), + listSessions: activity.listSessions, + }, { + candidate: session.session.toString(), + listSessions: 2, + }); + })); + test('two windows race for one reservation; only visible surface consumes the episode', () => runWithFakedTimers({}, async () => { + const storage = store.add(new InMemoryStorageService()); + const a = create(storage); + const b = create(storage); + await timeout(101); + assert.deepStrictEqual(await Promise.all([a.service.wouldShow('agentsWindow'), b.service.wouldShow('editorWindow')]), [true, true]); + const results = await Promise.all([a.service.reservePresentation(), b.service.reservePresentation()]); + assert.strictEqual(results.filter(Boolean).length, 1); + const winner = results[0] ? a : b; + const loser = results[0] ? b : a; + await winner.service.releasePresentation(); + assert.strictEqual(await loser.service.wouldShow('editorWindow'), true); + assert.strictEqual(await loser.service.reservePresentation(), true); + assert.strictEqual(await loser.service.markVisible('editorWindow', loser.service.candidate.get()!), true); + assert.strictEqual(await winner.service.wouldShow('agentsWindow'), false); + assert.deepStrictEqual([winner.order.filter(event => event === 'shown').length, loser.order.filter(event => event === 'shown').length], [0, 1]); + })); + test('background windows cannot trigger or claim; manual changes never permanently complete', () => runWithFakedTimers({}, async () => { + const storage = store.add(new InMemoryStorageService()); + const { service, host, session, order } = create(storage); + await timeout(101); + host.hasFocus = false; + assert.deepStrictEqual([await service.wouldShow('editorWindow'), await service.reservePresentation(), order], [false, false, []]); + host.hasFocus = true; + await service.wouldShow('editorWindow'); + await service.reservePresentation(); + await service.markVisible('editorWindow', service.candidate.get()!); + session.model = { id: target.id }; + assert.strictEqual(await service.resolve(), undefined); + assert.strictEqual((storage.getObject(CODEX_CONTINUATION_STORAGE_KEY, StorageScope.APPLICATION_SHARED, {}) as { permanent?: string }).permanent, undefined); + })); + const blockedQuotas: { name: string; entitlement?: ChatEntitlement; quotas: IChatEntitlementService['quotas'] }[] = [ + { name: 'included premium quota exhausted', quotas: { premiumChat: { unlimited: false, percentRemaining: 0 }, additionalUsageEnabled: false } }, + { name: 'chat quota exhausted despite additional usage', quotas: { premiumChat: { unlimited: false, percentRemaining: 50 }, chat: { unlimited: false, percentRemaining: 0 }, additionalUsageEnabled: true } }, + { name: 'Business allowance blocked', entitlement: ChatEntitlement.Business, quotas: { premiumChat: { unlimited: true, percentRemaining: 100, hasQuota: false }, additionalUsageEnabled: true } }, + { name: 'Enterprise allowance blocked', entitlement: ChatEntitlement.Enterprise, quotas: { premiumChat: { unlimited: false, percentRemaining: 50, hasQuota: false }, additionalUsageEnabled: true } }, + { name: 'unknown premium allowance', quotas: {} }, + ]; + for (const rateLimit of ['sessionRateLimit', 'weeklyRateLimit'] as const) { + for (const percentRemaining of [0, 10]) { + blockedQuotas.push({ + name: `${rateLimit} with ${percentRemaining} percent remaining`, + quotas: { premiumChat: { unlimited: false, percentRemaining: 100 }, [rateLimit]: { unlimited: false, percentRemaining } }, + }); + } + } + for (const blocked of blockedQuotas) { + test(`suppresses both surfaces when Copilot is unavailable: ${blocked.name}`, () => runWithFakedTimers({}, async () => { + const { service, entitlement, activity, order } = create(store.add(new InMemoryStorageService())); + entitlement.entitlement = blocked.entitlement ?? ChatEntitlement.Pro; + entitlement.quotas = blocked.quotas; + await timeout(101); + + assert.deepStrictEqual({ + candidate: service.candidate.get(), + agents: await service.wouldShow('agentsWindow'), + editor: await service.wouldShow('editorWindow'), + reserved: await service.reservePresentation(), + listSessions: activity.listSessions, + telemetry: order, + }, { candidate: undefined, agents: false, editor: false, reserved: false, listSessions: 0, telemetry: [] }); + })); + } + test('withdraws eligibility on a live Copilot quota update and restores it when quota returns', () => runWithFakedTimers({}, async () => { + const { service, entitlement, quotaChanged } = create(store.add(new InMemoryStorageService())); + await timeout(101); + const original = service.candidate.get()!; + assert.ok(original); + + entitlement.quotas = { ...entitlement.quotas, additionalUsageEnabled: false }; + quotaChanged.fire(); + await timeout(101); + const blocked = { candidate: service.candidate.get(), resolved: await service.resolve(original), canShow: await service.wouldShow('agentsWindow') }; + + entitlement.quotas = { premiumChat: { unlimited: false, percentRemaining: 100 } }; + quotaChanged.fire(); + await timeout(101); + assert.deepStrictEqual({ blocked, restored: service.candidate.get()?.session.session.toString() }, { + blocked: { candidate: undefined, resolved: undefined, canShow: false }, restored: original.session.session.toString(), + }); + })); + test('focus lost while the authoritative claim is read does not consume an episode', () => runWithFakedTimers({}, async () => { + let onRead = () => { }; + const storage = store.add(new class extends InMemoryStorageService { + override async readApplicationSharedValue(key: string): Promise { + const value = await super.readApplicationSharedValue(key); + onRead(); + return value; + } + }()); + const { service, host, order } = create(storage); + await timeout(101); + await service.wouldShow('editorWindow'); + await service.reservePresentation(); + onRead = () => { host.hasFocus = false; }; + assert.strictEqual(await service.markVisible('editorWindow', service.candidate.get()!), false); + assert.deepStrictEqual(order, ['trigger']); + assert.strictEqual((storage.getObject(CODEX_CONTINUATION_STORAGE_KEY, StorageScope.APPLICATION_SHARED, {}) as { episode?: object }).episode, undefined); + })); + test('a closed surface cannot claim through a late visibility callback', () => runWithFakedTimers({}, async () => { + const { service, order } = create(store.add(new InMemoryStorageService())); + await timeout(101); + await service.wouldShow('agentsWindow'); + await service.reservePresentation(); + assert.strictEqual(await service.markVisible('agentsWindow', service.candidate.get()!, () => false), false); + assert.deepStrictEqual(order, ['trigger']); + })); + test('native active-window check excludes a background renderer with emulated document focus', () => runWithFakedTimers({}, async () => { + const { service, host, order } = create(store.add(new InMemoryStorageService())); + await timeout(101); + host.lastFocused = false; + assert.deepStrictEqual([await service.wouldShow('editorWindow'), await service.reservePresentation(), order], [false, false, []]); + })); +}); diff --git a/src/vs/workbench/services/assignment/common/assignmentFilters.ts b/src/vs/workbench/services/assignment/common/assignmentFilters.ts index 3c4599bdce1f3f..e5f316eec4f003 100644 --- a/src/vs/workbench/services/assignment/common/assignmentFilters.ts +++ b/src/vs/workbench/services/assignment/common/assignmentFilters.ts @@ -261,6 +261,7 @@ export class CopilotAssignmentFilterProvider extends Disposable implements IExpe */ export enum GitHubAssignmentsFilter { CopilotTrackingId = 'copilottrackingid', + /** Whether the account is staff or belongs to a recognized internal organization. */ IsGhOrMsftStaff = 'github_core_isghormsftstaff', GhMsftOrExternal = 'github_core_ghmsftorexternal', } @@ -272,6 +273,7 @@ export enum GitHubAssignmentsFilter { export class GitHubCoreAssignmentsFilterProvider extends Disposable implements IExperimentationFilterProvider { private copilotTrackingId: string | undefined; private internalOrg: 'vscode' | 'github' | 'microsoft' | undefined; + private isInternal = false; private readonly _onDidChangeFilters = this._register(new Emitter()); readonly onDidChangeFilters = this._onDidChangeFilters.event; @@ -288,13 +290,15 @@ export class GitHubCoreAssignmentsFilterProvider extends Disposable implements I private update(): void { const newTrackingId = this._chatEntitlementService.copilotTrackingId ?? this.copilotTrackingId; const newInternalOrg = getInternalOrg(this._chatEntitlementService.organisations); + const newIsInternal = this._chatEntitlementService.isInternal; - if (this.copilotTrackingId === newTrackingId && this.internalOrg === newInternalOrg) { + if (this.copilotTrackingId === newTrackingId && this.internalOrg === newInternalOrg && this.isInternal === newIsInternal) { return; } this.copilotTrackingId = newTrackingId; this.internalOrg = newInternalOrg; + this.isInternal = newIsInternal; this._onDidChangeFilters.fire(); } @@ -313,13 +317,9 @@ export class GitHubCoreAssignmentsFilterProvider extends Disposable implements I case GitHubAssignmentsFilter.CopilotTrackingId: return copilotTrackingId ?? null; case GitHubAssignmentsFilter.IsGhOrMsftStaff: - return internalOrg ? '1' : '0'; + return this._chatEntitlementService.isInternal ? '1' : '0'; case GitHubAssignmentsFilter.GhMsftOrExternal: - return internalOrg === 'github' - ? 'github' - : (internalOrg === 'microsoft' || internalOrg === 'vscode') - ? 'microsoft' - : 'external'; + return internalOrg ?? 'external'; default: return null; } diff --git a/src/vs/workbench/services/assignment/test/common/assignmentFilters.test.ts b/src/vs/workbench/services/assignment/test/common/assignmentFilters.test.ts index 83146363c5e7fd..71addf6ed84e87 100644 --- a/src/vs/workbench/services/assignment/test/common/assignmentFilters.test.ts +++ b/src/vs/workbench/services/assignment/test/common/assignmentFilters.test.ts @@ -8,6 +8,7 @@ import { ICopilotTokenInfo } from '../../../../../base/common/defaultAccount.js' import { Emitter, Event } from '../../../../../base/common/event.js'; import { Lazy } from '../../../../../base/common/lazy.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { isInternalAccount } from '../../../../../platform/assignment/common/assignment.js'; import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; import { IExtensionDescription } from '../../../../../platform/extensions/common/extensions.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; @@ -154,6 +155,11 @@ suite('GitHubCoreAssignmentsFilterProvider', () => { override copilotTrackingId: string | undefined; override organisations: string[] | undefined; + isStaff: boolean | undefined; + + override get isInternal(): boolean { + return isInternalAccount(this.isStaff, this.organisations); + } fireChange(): void { this._onDidChangeEntitlement.fire(); @@ -201,19 +207,62 @@ suite('GitHubCoreAssignmentsFilterProvider', () => { test('reflects organization classification changes', () => { const entitlement = new TestChatEntitlementService(); - entitlement.organisations = undefined; - const provider = createProvider(entitlement); + const organizations = [ + undefined, + ['microsoft'], + ['github'], + ['Visual-Studio-Code'], + ['microsoft', 'github', 'Visual-Studio-Code'], + ['unrecognized-org'], + [], + undefined, + ]; + + assert.deepStrictEqual(organizations.map(organisations => { + entitlement.organisations = organisations; + entitlement.fireChange(); + const filters = provider.getFilters(); + return [ + filters.get(GitHubAssignmentsFilter.GhMsftOrExternal), + filters.get(GitHubAssignmentsFilter.IsGhOrMsftStaff), + ]; + }), [ + ['external', '0'], + ['microsoft', '1'], + ['github', '1'], + ['vscode', '1'], + ['vscode', '1'], + ['external', '0'], + ['external', '0'], + ['external', '0'], + ]); + }); - assert.strictEqual(provider.getFilterValue(GitHubAssignmentsFilter.IsGhOrMsftStaff), '0'); - assert.strictEqual(provider.getFilterValue(GitHubAssignmentsFilter.GhMsftOrExternal), 'external'); - - entitlement.organisations = ['microsoft']; - assert.strictEqual(provider.getFilterValue(GitHubAssignmentsFilter.IsGhOrMsftStaff), '1'); - assert.strictEqual(provider.getFilterValue(GitHubAssignmentsFilter.GhMsftOrExternal), 'microsoft'); + test('includes staff without internal organizations and notifies on staff-only changes', () => { + const entitlement = new TestChatEntitlementService(); + entitlement.organisations = ['contoso']; + entitlement.isStaff = true; + const provider = createProvider(entitlement); + let changes = 0; + disposables.add(provider.onDidChangeFilters(() => changes++)); - entitlement.organisations = ['Visual-Studio-Code']; - assert.strictEqual(provider.getFilterValue(GitHubAssignmentsFilter.GhMsftOrExternal), 'microsoft'); + assert.deepStrictEqual([true, false, false, true, undefined].map(isStaff => { + entitlement.isStaff = isStaff; + entitlement.fireChange(); + const filters = provider.getFilters(); + return [ + filters.get(GitHubAssignmentsFilter.IsGhOrMsftStaff), + filters.get(GitHubAssignmentsFilter.GhMsftOrExternal), + changes, + ]; + }), [ + ['1', 'external', 0], + ['0', 'external', 1], + ['0', 'external', 1], + ['1', 'external', 2], + ['0', 'external', 3], + ]); }); test('fires onDidChangeFilters only when relevant inputs change', () => { diff --git a/src/vs/workbench/services/chat/common/chatEntitlementService.ts b/src/vs/workbench/services/chat/common/chatEntitlementService.ts index c84b31cf634f76..29ce4272e78893 100644 --- a/src/vs/workbench/services/chat/common/chatEntitlementService.ts +++ b/src/vs/workbench/services/chat/common/chatEntitlementService.ts @@ -621,10 +621,10 @@ export class ChatEntitlementService extends Disposable implements IChatEntitleme this._onDidChangeQuotaExceeded.fire(); } - const sessionRateLimitChanged = oldQuota.sessionRateLimit?.percentRemaining !== quotas.sessionRateLimit?.percentRemaining; - const weeklyRateLimitChanged = oldQuota.weeklyRateLimit?.percentRemaining !== quotas.weeklyRateLimit?.percentRemaining; + const sessionRateLimitChanged = this.compareQuotas(oldQuota.sessionRateLimit, quotas.sessionRateLimit).changed.remaining; + const weeklyRateLimitChanged = this.compareQuotas(oldQuota.weeklyRateLimit, quotas.weeklyRateLimit).changed.remaining; - if (chatChanged.remaining || completionsChanged.remaining || premiumChatChanged.remaining || sessionRateLimitChanged || weeklyRateLimitChanged || oldQuota.usageBasedBilling !== quotas.usageBasedBilling) { + if (chatChanged.remaining || completionsChanged.remaining || premiumChatChanged.remaining || sessionRateLimitChanged || weeklyRateLimitChanged || oldQuota.usageBasedBilling !== quotas.usageBasedBilling || oldQuota.additionalUsageEnabled !== quotas.additionalUsageEnabled) { this._onDidChangeQuotaRemaining.fire(); } @@ -655,6 +655,9 @@ export class ChatEntitlementService extends Disposable implements IChatEntitleme changed: { exceeded: (oldQuota?.percentRemaining === 0) !== (newQuota?.percentRemaining === 0), remaining: oldQuota?.percentRemaining !== newQuota?.percentRemaining + // Pooled or newly limited allowances can become unusable without a percentage change. + || oldQuota?.hasQuota !== newQuota?.hasQuota + || oldQuota?.unlimited !== newQuota?.unlimited || oldQuota?.usageBasedBilling !== newQuota?.usageBasedBilling // Unlimited plans report a constant percentage, so consumed credits are the only signal that usage moved. || oldQuota?.creditsUsed !== newQuota?.creditsUsed @@ -667,16 +670,7 @@ export class ChatEntitlementService extends Disposable implements IChatEntitleme } private updateContextKeys(): void { - const chatExhausted = this._quotas.chat?.percentRemaining === 0; - const premiumChatExhausted = this._quotas.premiumChat?.unlimited - ? this._quotas.premiumChat.hasQuota === false - : this._quotas.premiumChat?.percentRemaining === 0; - const additionalUsageEnabled = this._quotas.additionalUsageEnabled ?? false; - const isManagedPlan = this.entitlement === ChatEntitlement.Business || this.entitlement === ChatEntitlement.Enterprise; - - // For Business/Enterprise users, hasQuota === false is the authoritative signal - // that the org has blocked usage, regardless of additionalUsageEnabled. - this.chatQuotaExceededContextKey.set(chatExhausted || (premiumChatExhausted && (isManagedPlan || !additionalUsageEnabled))); + this.chatQuotaExceededContextKey.set(isChatQuotaExceeded(this.entitlement, this._quotas)); this.completionsQuotaExceededContextKey.set(this._quotas.completions?.percentRemaining === 0); } @@ -804,6 +798,22 @@ interface IEntitlements { readonly quotas?: IQuotas; } +/** Shared exhaustion semantics for both controls and continuation suggestions. */ +export function isChatQuotaExceeded(entitlement: ChatEntitlement, quotas: IChatEntitlementService['quotas']): boolean { + const premium = quotas.premiumChat; + const managed = entitlement === ChatEntitlement.Business || entitlement === ChatEntitlement.Enterprise; + const exhausted = premium?.unlimited ? premium.hasQuota === false : premium?.percentRemaining === 0; + return quotas.chat?.percentRemaining === 0 || (managed && premium?.hasQuota === false) || (exhausted && (managed || !quotas.additionalUsageEnabled)); +} + +/** Unknown premium allowance cannot establish that a paid route is usable. */ +export function hasUsableCopilotPremiumQuota(entitlement: ChatEntitlement, quotas: IChatEntitlementService['quotas']): boolean { + const premium = quotas.premiumChat; + return isProUser(entitlement) && !!premium + && (premium.unlimited || (Number.isFinite(premium.percentRemaining) && premium.percentRemaining >= 0 && premium.percentRemaining <= 100)) + && !isChatQuotaExceeded(entitlement, quotas); +} + export interface IQuotaSnapshot { readonly percentRemaining: number; readonly unlimited: boolean; diff --git a/src/vs/workbench/services/storage/browser/storageService.ts b/src/vs/workbench/services/storage/browser/storageService.ts index 0f3e0623dee4e5..fb0c7c8d96aeeb 100644 --- a/src/vs/workbench/services/storage/browser/storageService.ts +++ b/src/vs/workbench/services/storage/browser/storageService.ts @@ -47,6 +47,17 @@ export class BrowserStorageService extends AbstractStorageService { ); } + override async readApplicationSharedValue(key: string): Promise { + await this.initialize(); + return this.applicationSharedStorageDatabase!.getValue(key); + } + + override async compareAndSwapApplicationSharedValue(key: string, expectedValue: string | undefined, newValue: string): Promise<{ swapped: boolean; currentValue: string | undefined }> { + await this.initialize(); + this.updateKeyTarget(key, StorageScope.APPLICATION_SHARED, StorageTarget.MACHINE); + return this.applicationSharedStorageDatabase!.compareAndSwap(key, expectedValue, newValue); + } + async getApplicationStorageValue(key: string): Promise { return (await this.applicationStoragePromise.p).indexedDb.getValue(key); } diff --git a/src/vs/workbench/services/storage/electron-browser/storageService.ts b/src/vs/workbench/services/storage/electron-browser/storageService.ts index a5a726c538b02e..6a02706a46150e 100644 --- a/src/vs/workbench/services/storage/electron-browser/storageService.ts +++ b/src/vs/workbench/services/storage/electron-browser/storageService.ts @@ -7,7 +7,7 @@ import { IMainProcessService } from '../../../../platform/ipc/common/mainProcess import { IStorage, Storage, MigratingStorage } from '../../../../base/parts/storage/common/storage.js'; import { RemoteStorageService } from '../../../../platform/storage/common/storageService.js'; import { FallbackApplicationStorageDatabaseClient, ApplicationSharedStorageDatabaseClient } from '../../../../platform/storage/common/storageIpc.js'; -import { StorageScope } from '../../../../platform/storage/common/storage.js'; +import { StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IUserDataProfilesService } from '../../../../platform/userDataProfile/common/userDataProfile.js'; import { IAnyWorkspaceIdentifier } from '../../../../platform/workspace/common/workspace.js'; import { IUserDataProfileService } from '../../userDataProfile/common/userDataProfile.js'; @@ -27,6 +27,15 @@ export class NativeWorkbenchStorageService extends RemoteStorageService { this.registerListeners(); } + override readApplicationSharedValue(key: string): Promise { + return this.remoteService.getChannel('storage').call('getValue', { profile: undefined, workspace: undefined, applicationShared: true, key }); + } + + override async compareAndSwapApplicationSharedValue(key: string, expectedValue: string | undefined, newValue: string): Promise<{ swapped: boolean; currentValue: string | undefined }> { + this.updateKeyTarget(key, StorageScope.APPLICATION_SHARED, StorageTarget.MACHINE); + return this.remoteService.getChannel('storage').call('compareAndSwap', { profile: undefined, workspace: undefined, applicationShared: true, key, expectedValue, newValue }); + } + protected override createApplicationSharedStorage(): IStorage { const channel = this.remoteService.getChannel('storage'); const storageDataBaseClient = this._register(new ApplicationSharedStorageDatabaseClient(channel)); diff --git a/src/vs/workbench/services/storage/test/browser/storageService.test.ts b/src/vs/workbench/services/storage/test/browser/storageService.test.ts index 39588cc5bad090..82ff177aa5f589 100644 --- a/src/vs/workbench/services/storage/test/browser/storageService.test.ts +++ b/src/vs/workbench/services/storage/test/browser/storageService.test.ts @@ -131,6 +131,20 @@ flakySuite('StorageService (browser specific)', () => { serviceValue: 'second', }); }); + test('application-shared compare-and-swap stays machine-local and publishes the new value', async () => { + const result = await storageService.compareAndSwapApplicationSharedValue('shared-claim', undefined, 'owner'); + const rejected = await storageService.compareAndSwapApplicationSharedValue('shared-claim', undefined, 'other'); + deepStrictEqual({ + result, rejected, + value: await storageService.readApplicationSharedValue('shared-claim'), + cached: storageService.get('shared-claim', StorageScope.APPLICATION_SHARED), + machine: storageService.keys(StorageScope.APPLICATION_SHARED, StorageTarget.MACHINE).includes('shared-claim'), + synced: storageService.keys(StorageScope.APPLICATION_SHARED, StorageTarget.USER).includes('shared-claim'), + }, { + result: { swapped: true, currentValue: 'owner' }, rejected: { swapped: false, currentValue: 'owner' }, + value: 'owner', cached: 'owner', machine: true, synced: false, + }); + }); ensureNoDisposablesAreLeakedInTestSuite(); }); diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentLifecycle.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentLifecycle.fixture.ts index 1e471b61d34f01..b25e115fedc38a 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentLifecycle.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentLifecycle.fixture.ts @@ -27,7 +27,7 @@ import { ExtensionIdentifier } from '../../../../../platform/extensions/common/e import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { AgentHostSubagentProgress } from '../../../../contrib/chat/browser/agentSessions/agentHost/agentHostSubagentProgress.js'; -import { toAgentHostBackendSessionUri } from '../../../../contrib/chat/browser/agentSessions/agentHost/agentHostSessionUri.js'; +import { getLocalAgentHostSessionProvider } from '../../../../contrib/chat/browser/agentSessions/agentHost/agentHostSessionUri.js'; import { ISessionSummaryHoverService, SessionSummaryHoverService } from '../../../../contrib/chat/browser/agentSessions/sessionSummaryHoverService.js'; import { IChatWidgetService } from '../../../../contrib/chat/browser/chat.js'; import { AcceptToolConfirmationActionId, registerChatToolActions, SkipToolConfirmationActionId } from '../../../../contrib/chat/browser/actions/chatToolActions.js'; @@ -258,7 +258,8 @@ async function renderLifecycle(context: ComponentFixtureContext, name: string, s const publisher = disposableStore.add(new AgentHostSubagentProgress(parts => publish(root, parts))); const launch = async (id: string, title: string, parent?: string): Promise => { const lateClassification = scenario.phase === 'lateClassification'; - const backendSession = toAgentHostBackendSessionUri(model.sessionResource); + const provider = getLocalAgentHostSessionProvider(model.sessionResource); + const backendSession = provider ? model.sessionResource.with({ scheme: provider, fragment: '' }) : undefined; if (!backendSession) { throw new Error('Agent lifecycle fixture requires an agent-host session'); } diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts index af3e6e436c04cc..6dd9f90b87f2de 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts @@ -3,8 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Event } from '../../../../../base/common/event.js'; -import { Disposable, IReference } from '../../../../../base/common/lifecycle.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { Disposable, DisposableStore, IReference, toDisposable } from '../../../../../base/common/lifecycle.js'; import { constObservable, IObservable, observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; @@ -24,6 +24,7 @@ import { IUpdateService, StateType } from '../../../../../platform/update/common import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; import { ISharedWebContentExtractorService } from '../../../../../platform/webContentExtractor/common/webContentExtractor.js'; import { IAccessibleViewService } from '../../../../../platform/accessibility/browser/accessibleView.js'; +import { TestAccessibilityService } from '../../../../../platform/accessibility/test/common/testAccessibilityService.js'; import { IMarkdownRendererService, MarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; import { IWorkspace, IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { IDecorationsService } from '../../../../services/decorations/common/decorations.js'; @@ -487,3 +488,25 @@ export function registerChatFixtureServices(reg: ServiceRegistration, options: I override migrateTodos() { } }()); } + +/** + * Makes script-driven fixture animations honor the same motion classes as CSS animations. + */ +export class FixtureMotionAccessibilityService extends TestAccessibilityService { + + override onDidChangeReducedMotion: Event; + constructor(private readonly container: HTMLElement, store: DisposableStore) { + super(); + const onDidChangeReducedMotion = store.add(new Emitter()); + this.onDidChangeReducedMotion = onDidChangeReducedMotion.event; + const observer = new MutationObserver(() => onDidChangeReducedMotion.fire()); + for (let element: HTMLElement | null = container; element; element = element.parentElement) { + observer.observe(element, { attributes: true, attributeFilter: ['class'] }); + } + store.add(toDisposable(() => observer.disconnect())); + } + + override isMotionReduced(): boolean { + return !this.container.closest('.monaco-enable-motion') || !!this.container.closest('.monaco-reduce-motion, .disable-animations'); + } +} diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatGeneratedImages.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatGeneratedImages.fixture.ts new file mode 100644 index 00000000000000..876a9d93532114 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatGeneratedImages.fixture.ts @@ -0,0 +1,412 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../base/browser/dom.js'; +import { Button } from '../../../../../base/browser/ui/button/button.js'; +// eslint-disable-next-line local/code-import-patterns, local/code-amd-node-module +import { z } from 'zod'; +import { DeferredPromise, retry } from '../../../../../base/common/async.js'; +import { decodeBase64, encodeBase64, VSBuffer } from '../../../../../base/common/buffer.js'; +import { MarkdownString } from '../../../../../base/common/htmlContent.js'; +import { toDisposable } from '../../../../../base/common/lifecycle.js'; +import { isEqual } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { Range } from '../../../../../editor/common/core/range.js'; +import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; +import { localize } from '../../../../../nls.js'; +import { isIMenuItem, MenuId, MenuRegistry } from '../../../../../platform/actions/common/actions.js'; +import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; +import { ChatProgressAnimation, CollapsedToolsDisplayMode, ThinkingDisplayMode } from '../../../../contrib/chat/common/constants.js'; +import { ChatRequestModel } from '../../../../contrib/chat/common/model/chatModel.js'; +import { ChatToolInvocation } from '../../../../contrib/chat/common/model/chatProgressTypes/chatToolInvocation.js'; +import { ChatRequestTextPart } from '../../../../contrib/chat/common/requestParser/chatParserTypes.js'; +import { TestFileService } from '../../../common/workbenchTestServices.js'; +import { ComponentFixtureContext, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; +import { fixtureResourceUri, readFixtureBinaryResource } from '../fixtureResourceLoader.js'; +import { FixtureMotionAccessibilityService } from './chatFixtureUtils.js'; +import { IFixtureMessage, renderChatWidget } from './chatWidget.fixture.js'; + +function createImage(width: number, height: number): string { + const canvas = dom.$('canvas', { width, height }); + const context = canvas.getContext('2d')!; + context.fillStyle = '#3f687c'; + context.fillRect(0, 0, width, height); + context.fillStyle = '#e8c789'; + context.beginPath(); + context.arc(width * 0.65, height * 0.3, Math.min(width, height) * 0.15, 0, Math.PI * 2); + context.fill(); + context.fillStyle = '#c9dfd3'; + context.beginPath(); + context.moveTo(0, height); + context.lineTo(width * 0.4, height * 0.5); + context.lineTo(width, height); + context.fill(); + return canvas.toDataURL('image/png').split(',')[1]; +} + +async function renderGeneratedImage(context: ComponentFixtureContext, options: { width?: number; height?: number; landscape?: boolean; multiple?: boolean; progress?: ChatProgressAnimation; running?: boolean; expanded?: boolean; responseComplete?: boolean; toolId?: string; modelName?: string; reducedMotion?: boolean; failed?: boolean; earlierAttempt?: 'running' | 'failed' } = {}): Promise { + context.container.classList.add(options.reducedMotion ? 'monaco-reduce-motion' : 'monaco-enable-motion'); + const image = createImage(options.landscape ? 1600 : 800, options.landscape ? 800 : 1600); + const failureDetails = { + input: '{"prompt":"Draw an abstract mountain landscape"}', + output: [{ type: 'embed' as const, value: 'Image generation returned no usable image.', isText: true, mimeType: 'text/plain' }], + isError: true, + }; + const tool: NonNullable[number] = { + kind: 'tool', + toolId: options.toolId ?? 'image_generation', + displayName: 'Generate Image', + invocationMessage: 'Generating image', + pastTenseMessage: options.running ? undefined : options.failed ? 'Generated image failed' : options.modelName ? `Generated image with ${options.modelName}` : 'Generated image', + complete: !options.running, + toolSpecificData: options.running ? { + kind: 'input', + rawInput: '{"prompt":"Draw an abstract mountain landscape"}', + editable: false, + imageGeneration: { requestedModel: options.modelName ? { id: 'image-preview', name: options.modelName } : undefined }, + } : options.failed ? undefined : { kind: 'generatedImage' }, + resultDetails: options.running ? undefined : options.failed ? failureDetails : { + input: '{"prompt":"Draw an abstract mountain landscape"}', + output: Array.from({ length: options.multiple ? 2 : 1 }, () => ({ type: 'embed' as const, value: image, mimeType: 'image/png' })), + }, + }; + const height = options.height ?? (options.running && !options.earlierAttempt ? 560 : 720); + await renderChatWidget(context, { + width: options.width ?? 760, + height, + listHeight: height, + inputVisible: false, + thinkingStyle: ThinkingDisplayMode.Collapsed, + collapsedTools: CollapsedToolsDisplayMode.Always, + persistentProgress: options.progress ?? ChatProgressAnimation.Off, + collapseCompletedResponses: true, + menuItems: MenuRegistry.getMenuItems(MenuId.ChatToolOutputResourceToolbar).filter(isIMenuItem).map(item => ({ menuId: MenuId.ChatToolOutputResourceToolbar, item })), + additionalServices: registration => registration.defineInstance(IAccessibilityService, new FixtureMotionAccessibilityService(context.container, context.disposableStore)), + messages: [{ + user: 'Generate an abstract mountain landscape', + responseComplete: options.responseComplete ?? (!options.running && options.earlierAttempt !== 'running'), + assistant: [ + { kind: 'thinking', text: 'Planning the composition.' }, + ...(options.earlierAttempt ? [{ + ...tool, + pastTenseMessage: options.earlierAttempt === 'failed' ? 'Generated image failed' : undefined, + complete: options.earlierAttempt === 'failed', + toolSpecificData: options.earlierAttempt === 'running' ? tool.toolSpecificData : undefined, + resultDetails: options.earlierAttempt === 'failed' ? failureDetails : undefined, + }] : []), + tool, + ...(!options.running && !options.failed ? [{ kind: 'markdown' as const, text: 'Here is the generated image.' }] : []), + ], + }], + }); + if (options.expanded) { + const dropdown = context.container.querySelector('.chat-confirmation-widget-title'); + if (!dropdown) { + throw new Error('The running image tool must expose its input dropdown'); + } + dropdown.click(); + } + if (!options.running && !options.failed) { + await retry(async () => { + const images = [...context.container.querySelectorAll('.chat-generated-image-result img')]; + if (images.length !== (options.multiple ? 2 : 1)) { + throw new Error('Generated image previews are not ready'); + } + await Promise.all(images.map(image => image.decode())); + }, 50, 20); + } +} + +const previewInput = z.object({ + enableAnimations: z.boolean().default(true), + harness: z.enum(['Copilot', 'Codex']).default('Copilot'), + state: z.enum(['Generating', 'Completed', 'Failed']).default('Generating'), + earlierAttempt: z.enum(['None', 'Generating', 'Failed']).default('None'), + narrow: z.boolean().default(false), + reducedMotion: z.boolean().default(false), +}); + +const loadingLifecycleInput = z.object({ + enableAnimations: z.boolean().default(false), + harness: z.enum(['Copilot', 'Codex']).default('Copilot'), + source: z.enum(['Embedded', 'Referenced']).default('Embedded'), + viewportHeight: z.number().min(240).max(900).default(300), + sampleImage: z.boolean().default(false), + reducedMotion: z.boolean().default(false), + narrow: z.boolean().default(false), +}); + +const revealPreviewInput = loadingLifecycleInput.extend({ + enableAnimations: z.boolean().default(true), + viewportHeight: z.number().min(240).max(900).default(720), + sampleImage: z.boolean().default(true), +}); + +async function renderImageLoadingLifecycle(context: ComponentFixtureContext): Promise { + const { container, disposableStore } = context; + const input = loadingLifecycleInput.parse(context.input); + container.classList.add(input.reducedMotion ? 'monaco-reduce-motion' : 'monaco-enable-motion'); + const controls = dom.append(container, dom.$('div')); + controls.style.display = 'flex'; + controls.style.gap = 'var(--vscode-spacing-size80)'; + controls.style.marginBottom = 'var(--vscode-spacing-size120)'; + const createButton = (label: string) => { + const button = disposableStore.add(new Button(controls, defaultButtonStyles)); + button.label = label; + return button; + }; + const completeButton = createButton(localize('generatedImage.fixture.complete', "Complete Generation")); + const loadButton = input.source === 'Referenced' ? createButton(localize('generatedImage.fixture.load', "Load Image")) : undefined; + const followupButton = createButton(localize('generatedImage.fixture.followup', "Send Follow-Up")); + const completeFollowupButton = createButton(localize('generatedImage.fixture.completeFollowup', "Complete Follow-Up")); + followupButton.enabled = false; + completeFollowupButton.enabled = false; + if (loadButton) { + loadButton.enabled = false; + } + + const chatContainer = dom.append(container, dom.$('div')); + chatContainer.dataset.imageReadCount = '0'; + chatContainer.dataset.imageLoadCount = '0'; + disposableStore.add(dom.addDisposableListener(chatContainer, 'load', event => { + if (dom.isHTMLElement(event.target) && event.target.matches('.chat-generated-image-result img')) { + chatContainer.dataset.imageLoadCount = String(Number(chatContainer.dataset.imageLoadCount) + 1); + } + }, true)); + const image = input.sampleImage + ? encodeBase64(VSBuffer.wrap(new Uint8Array(await readFixtureBinaryResource(fixtureResourceUri('src/vs/workbench/test/browser/componentFixtures/chat/media/generated-image.png'))))) + : createImage(800, 1200); + const mimeType = 'image/png'; + const imageResource = URI.file('/fixture/generated-image.png'); + const pendingImage = new DeferredPromise(); + disposableStore.add(toDisposable(() => { + if (!pendingImage.isSettled) { + void pendingImage.complete(decodeBase64(image)); + } + })); + if (loadButton) { + disposableStore.add(loadButton.onDidClick(() => { + loadButton.enabled = false; + void pendingImage.complete(decodeBase64(image)); + })); + } + + await renderChatWidget({ ...context, container: chatContainer }, { + width: input.narrow ? 360 : 760, + height: input.viewportHeight, + listHeight: input.viewportHeight, + defaultElementHeight: 200, + inputVisible: false, + persistentProgress: ChatProgressAnimation.Draw, + collapseCompletedResponses: true, + menuItems: MenuRegistry.getMenuItems(MenuId.ChatToolOutputResourceToolbar).filter(isIMenuItem).map(item => ({ menuId: MenuId.ChatToolOutputResourceToolbar, item })), + additionalServices: registration => { + registration.defineInstance(IAccessibilityService, new FixtureMotionAccessibilityService(container, disposableStore)); + registration.defineInstance(IFileService, disposableStore.add(new class extends TestFileService { + override async readFile(resource: URI) { + const file = await super.readFile(resource); + if (!isEqual(resource, imageResource)) { + return file; + } + chatContainer.dataset.imageReadCount = String(Number(chatContainer.dataset.imageReadCount) + 1); + return { ...file, value: await pendingImage.p }; + } + }())); + }, + messages: [{ + user: 'Generate an abstract mountain landscape', + responseComplete: false, + assistant: [{ kind: 'thinking', text: 'Planning the composition.' }, { + kind: 'tool', + toolId: input.harness === 'Copilot' ? 'image_generation' : 'image_gen.imagegen', + displayName: 'Generate Image', + invocationMessage: 'Generating image', + pastTenseMessage: 'Generated image', + complete: false, + toolSpecificData: { + kind: 'input', + rawInput: '{"prompt":"Draw an abstract mountain landscape"}', + editable: false, + imageGeneration: {}, + }, + }], + }], + onRendered: ({ model, listWidget }) => { + let pendingFollowup: ChatRequestModel | undefined; + const request = model.getRequests()[0]; + const tool = request.response?.response.value.find(part => part.kind === 'toolInvocation'); + if (!(tool instanceof ChatToolInvocation)) { + throw new Error('The image generation fixture did not create its tool invocation.'); + } + disposableStore.add(completeButton.onDidClick(async () => { + completeButton.enabled = false; + await tool.didExecuteTool({ + content: [], + toolSpecificData: { kind: 'generatedImage' }, + toolResultDetails: { + input: '{"prompt":"Draw an abstract mountain landscape"}', + output: [input.source === 'Referenced' + ? { type: 'ref', uri: imageResource, mimeType } + : { type: 'embed', value: image, mimeType }], + }, + }); + model.acceptResponseProgress(request, { kind: 'markdownContent', content: new MarkdownString('Task completed: Generated the requested image.') }); + request.response?.complete(); + followupButton.enabled = true; + if (loadButton) { + loadButton.enabled = true; + } + })); + disposableStore.add(followupButton.onDidClick(() => { + followupButton.enabled = false; + completeFollowupButton.enabled = true; + const text = 'Describe the image.'; + pendingFollowup = model.addRequest({ + text, + parts: [new ChatRequestTextPart(new OffsetRange(0, text.length), new Range(1, 1, 1, text.length + 1), text)], + }, { variables: [] }, 0); + listWidget.refresh(); + listWidget.scrollToEnd(); + })); + disposableStore.add(completeFollowupButton.onDidClick(() => { + if (!pendingFollowup) { + throw new Error('The image loading fixture has no pending follow-up.'); + } + model.acceptResponseProgress(pendingFollowup, { kind: 'markdownContent', content: new MarkdownString('A sun above an abstract mountain landscape.') }); + pendingFollowup.response?.complete(); + pendingFollowup = undefined; + completeFollowupButton.enabled = false; + followupButton.enabled = true; + })); + }, + }); +} + +export default defineThemedFixtureGroup({ path: 'chat/generatedImages/' }, { + GlyphRevealFinal: defineComponentFixture({ + virtualTime: { enabled: false }, + additionalThemes: ['darkHighContrast', 'lightHighContrast'], + labels: { kind: 'animated' }, + inputSchema: revealPreviewInput, + render: context => renderImageLoadingLifecycle({ ...context, input: revealPreviewInput.parse(context.input) }), + }), + LoadingLifecycle: defineComponentFixture({ + virtualTime: { enabled: false }, + inputSchema: loadingLifecycleInput, + render: renderImageLoadingLifecycle, + }), + Lifecycle: defineComponentFixture({ + virtualTime: { enabled: false }, + labels: { kind: 'animated' }, + inputSchema: z.object({ enableAnimations: z.boolean().default(true) }), + render: async context => { + context.container.style.display = 'flex'; + context.container.style.gap = 'var(--vscode-spacing-size160)'; + for (const state of ['Generating', 'Generated', 'Failed'] as const) { + const column = dom.append(context.container, dom.$('section')); + const heading = dom.append(column, dom.$('h2', undefined, state)); + heading.style.fontSize = 'var(--vscode-fontSize-body1)'; + heading.style.fontWeight = 'var(--vscode-fontWeight-semiBold)'; + heading.style.margin = '0 0 var(--vscode-spacing-size120)'; + const container = dom.append(column, dom.$('div')); + await renderGeneratedImage({ ...context, container }, { + width: 360, + height: 560, + landscape: true, + toolId: 'image_generation', + running: state === 'Generating', + failed: state === 'Failed', + progress: ChatProgressAnimation.Draw, + }); + } + }, + }), + Preview: defineComponentFixture({ + virtualTime: { enabled: false }, + additionalThemes: ['darkHighContrast', 'lightHighContrast'], + labels: { kind: 'animated' }, + inputSchema: previewInput, + render: context => { + const input = previewInput.parse(context.input); + return renderGeneratedImage(context, { + toolId: input.harness === 'Copilot' ? 'image_generation' : 'image_gen.imagegen', + running: input.state === 'Generating', + failed: input.state === 'Failed', + earlierAttempt: input.earlierAttempt === 'None' ? undefined : input.earlierAttempt === 'Generating' ? 'running' : 'failed', + width: input.narrow ? 360 : 760, + reducedMotion: input.reducedMotion, + progress: ChatProgressAnimation.Draw, + landscape: true, + }); + }, + }), + Portrait: defineComponentFixture({ virtualTime: { enabled: false }, render: context => renderGeneratedImage(context) }), + Landscape: defineComponentFixture({ virtualTime: { enabled: false }, render: context => renderGeneratedImage(context, { landscape: true }) }), + Narrow: defineComponentFixture({ virtualTime: { enabled: false }, render: context => renderGeneratedImage(context, { width: 360, landscape: true }) }), + Gallery: defineComponentFixture({ virtualTime: { enabled: false }, render: context => renderGeneratedImage(context, { multiple: true }) }), + PersistentProgress: defineComponentFixture({ virtualTime: { enabled: false }, render: context => renderGeneratedImage(context, { progress: ChatProgressAnimation.Draw }) }), + Running: defineComponentFixture({ labels: { kind: 'animated' }, render: context => renderGeneratedImage(context, { running: true }) }), + GeneratingWithImageModel: defineComponentFixture({ + additionalThemes: ['darkHighContrast', 'lightHighContrast'], + render: context => renderGeneratedImage(context, { modelName: 'GPT Image 2.5 Sunburst', running: true, reducedMotion: true, progress: ChatProgressAnimation.Draw, width: 360 }), + }), + GeneratingWithPromptExpanded: defineComponentFixture({ + render: context => renderGeneratedImage(context, { modelName: 'GPT Image 2.5 Sunburst', running: true, expanded: true, reducedMotion: true, progress: ChatProgressAnimation.Draw, height: 800 }), + }), + CompletedWithImageModel: defineComponentFixture({ + virtualTime: { enabled: false }, + render: context => renderGeneratedImage(context, { modelName: 'GPT Image 2.5 Sunburst', landscape: true }), + }), + CopilotGenerating: defineComponentFixture({ + additionalThemes: ['darkHighContrast', 'lightHighContrast'], + labels: { kind: 'animated' }, + expectedVisualDescriptions: ['An expandable tool row with an image icon and no shimmer sits above a compact band of binary digits and denser accent-colored glyphs. The band aligns with the image icon rather than the indented tool label. Expanding the row reveals the submitted prompt while the band stays visible. Painting-themed phrases rotate only in the persistent footer. Reduced motion and high contrast show a still glyph band.'], + render: context => renderGeneratedImage(context, { toolId: 'image_generation', running: true, progress: ChatProgressAnimation.Draw }), + }), + CodexGenerating: defineComponentFixture({ + labels: { kind: 'animated' }, + render: context => renderGeneratedImage(context, { toolId: 'image_gen.imagegen', running: true, progress: ChatProgressAnimation.Draw }), + }), + CopilotOverlapping: defineComponentFixture({ + virtualTime: { enabled: false }, + labels: { kind: 'animated' }, + inputSchema: z.object({ enableAnimations: z.boolean().default(true) }), + expectedVisualDescriptions: ['Two concurrent image-generation attempts each retain an expandable input row and share exactly one compact glyph band and one painting-themed progress footer.'], + render: context => renderGeneratedImage(context, { toolId: 'image_generation', running: true, earlierAttempt: 'running', progress: ChatProgressAnimation.Draw }), + }), + CodexOverlapping: defineComponentFixture({ + virtualTime: { enabled: false }, + labels: { kind: 'animated' }, + inputSchema: z.object({ enableAnimations: z.boolean().default(true) }), + render: context => renderGeneratedImage(context, { toolId: 'image_gen.imagegen', running: true, earlierAttempt: 'running', progress: ChatProgressAnimation.Draw }), + }), + GeneratingAfterFailure: defineComponentFixture({ + labels: { kind: 'animated' }, + render: context => renderGeneratedImage(context, { toolId: 'image_generation', running: true, earlierAttempt: 'failed', progress: ChatProgressAnimation.Draw }), + }), + CompletedAfterFailure: defineComponentFixture({ + virtualTime: { enabled: false }, + render: context => renderGeneratedImage(context, { toolId: 'image_generation', earlierAttempt: 'failed', landscape: true, progress: ChatProgressAnimation.Draw }), + }), + GeneratingNarrow: defineComponentFixture({ + labels: { kind: 'animated' }, + render: context => renderGeneratedImage(context, { width: 360, toolId: 'image_gen.imagegen', running: true, progress: ChatProgressAnimation.Draw }), + }), + GeneratingReducedMotion: defineComponentFixture({ + render: context => renderGeneratedImage(context, { toolId: 'image_generation', running: true, reducedMotion: true, progress: ChatProgressAnimation.Draw }), + }), + Failed: defineComponentFixture({ + expectedVisualDescriptions: ['A Generated image failed tool dropdown with an error indicator remains available to inspect the prompt and failure output. There is no generation placeholder or large generated image.'], + render: context => renderGeneratedImage(context, { toolId: 'image_generation', failed: true, progress: ChatProgressAnimation.Draw }), + }), + CompletedTool: defineComponentFixture({ + virtualTime: { enabled: false }, + labels: { kind: 'animated' }, + expectedVisualDescriptions: ['A Generated image tool dropdown with its image icon appears above the large generated image, whose left edge aligns with the icon rather than the indented tool label. The Save action is immediately to the right of the image. The image stays visible with the dropdown collapsed, and there is no generation placeholder.'], + render: context => renderGeneratedImage(context, { responseComplete: false }), + }), +}); diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatInput.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatInput.fixture.ts index 71780935b9816c..4249bbcb1364bc 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatInput.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatInput.fixture.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../../base/browser/dom.js'; +import { assert } from '../../../../../base/common/assert.js'; import { Event } from '../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { observableValue } from '../../../../../base/common/observable.js'; @@ -299,6 +300,26 @@ export default defineThemedFixtureGroup({ path: 'chat/input/' }, { VoiceModeListening: defineComponentFixture({ render: context => renderChatInput(context, { voiceControl: 'voiceListening' }) }), VoiceModeSpeaking: defineComponentFixture({ render: context => renderChatInput(context, { voiceControl: 'voiceSpeaking' }) }), VoiceModeDisconnect: defineComponentFixture({ render: context => renderChatInput(context, { voiceControl: 'voiceDisconnect' }) }), + // The segmented voice pill at the chat view's minimum width: the pickers + // collapse into the overflow menu so the send button and the voice pill stay + // inside the input (#331228). + NarrowWithVoiceInputMode: defineComponentFixture({ + virtualTime: { enabled: false }, + render: async context => { + await renderChatInput(context, { voiceInputMode: true, width: 150 }); + const inputContainer = context.container.querySelector('.chat-input-container'); + const inputToolbar = context.container.querySelector('.chat-input-toolbar'); + const submit = context.container.querySelector('.chat-execute-toolbar .chat-submit-button'); + assert(!!inputContainer && !!inputToolbar && !!submit && submit.checkVisibility()); + const submitOverflow = submit.getBoundingClientRect().right - inputContainer.getBoundingClientRect().right; + assert(submitOverflow <= 0, `The send button must not overflow the chat input, got ${submitOverflow}px of overflow.`); + const inputToolbarRight = inputToolbar.getBoundingClientRect().right; + for (const item of inputToolbar.querySelectorAll('.action-item')) { + assert(item.getBoundingClientRect().right <= inputToolbarRight + 1, 'Input toolbar actions must move into the overflow menu instead of being clipped.'); + } + assert(!!inputToolbar.querySelector('.monaco-action-bar.has-overflow'), 'The collapsed pickers must stay reachable from the overflow menu.'); + }, + }), // Where the pet lands, with and without a notice docked above the input (#332570). WithPet: defineComponentFixture({ render: context => renderChatInput(context, { pet: true }) }), 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 137995fc9cb0a4..836c3fc2f587ed 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts @@ -28,7 +28,7 @@ import { ChatInputPart, IChatInputPartOptions, IChatInputStyles } from '../../.. import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IMarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; import { IChatWidget, IChatWidgetService } from '../../../../contrib/chat/browser/chat.js'; -import { ChatMcpServersStarting, ElicitationState, IChatExternalEdit, IChatQuestion, IChatQuestionAnswers, IChatSearchToolInvocationData, IChatService, IChatSimpleToolInvocationData, IChatSystemNotificationPart, IChatToolInvocation, IChatToolInvocationSerialized, ToolConfirmKind } from '../../../../contrib/chat/common/chatService/chatService.js'; +import { ChatMcpServersStarting, ElicitationState, IChatExternalEdit, IChatGeneratedImageData, IChatQuestion, IChatQuestionAnswers, IChatSearchToolInvocationData, IChatService, IChatSimpleToolInvocationData, IChatSystemNotificationPart, IChatToolInputInvocationData, IChatToolInvocation, IChatToolInvocationSerialized, ToolConfirmKind } from '../../../../contrib/chat/common/chatService/chatService.js'; import { ChatElicitationRequestPart } from '../../../../contrib/chat/common/model/chatProgressTypes/chatElicitationRequestPart.js'; import { ChatQuestionCarouselData } from '../../../../contrib/chat/common/model/chatProgressTypes/chatQuestionCarouselData.js'; import { ChatPlanReviewData } from '../../../../contrib/chat/common/model/chatProgressTypes/chatPlanReviewData.js'; @@ -88,7 +88,7 @@ export interface IFixtureMessage { | { kind: 'thinking'; text: string; id?: string; generatedTitle?: string } | IChatExternalEdit | { kind: 'systemNotification'; notification: IChatSystemNotificationPart } - | { kind: 'tool'; toolId: string; displayName: string; invocationMessage: string; pastTenseMessage?: string; streaming?: boolean; complete?: boolean; source?: ToolDataSource; approval?: 'pre' | 'post' | 'denied'; toolSpecificData?: IChatSimpleToolInvocationData | IChatSearchToolInvocationData; resultDetails?: IToolResultInputOutputDetails; resultError?: string | true } + | { kind: 'tool'; toolId: string; displayName: string; invocationMessage: string; pastTenseMessage?: string; progressMessage?: string; streaming?: boolean; complete?: boolean; source?: ToolDataSource; approval?: 'pre' | 'post' | 'denied'; toolSpecificData?: IChatSimpleToolInvocationData | IChatSearchToolInvocationData | IChatGeneratedImageData | IChatToolInputInvocationData; resultDetails?: IToolResultInputOutputDetails; resultError?: string | true } | { kind: 'questionCarousel'; questions: IChatQuestion[]; message?: string; allowSkip?: boolean; data?: IChatQuestionAnswers; isUsed?: boolean; answerPresentation?: 'conversation' } | { kind: 'planReview'; title: string; content: string } | { kind: 'mcpStarting'; servers: readonly string[]; local?: boolean; blocking?: boolean; background?: boolean } @@ -117,6 +117,7 @@ export interface IChatWidgetFixtureOptions { readonly width?: number; readonly height?: number; readonly listHeight?: number; + readonly defaultElementHeight?: number; /** Omit the auxiliary-bar ancestry when the caller supplies the owning workbench part. */ readonly useAuxiliaryBarWrapper?: boolean; /** Total horizontal padding reserved when laying out response content and embedded editors. */ @@ -478,6 +479,9 @@ export async function renderChatWidget(context: ComponentFixtureContext, options if (part.streaming) { toolInvocation.updateStreamingMessage(new MarkdownString(part.invocationMessage)); } + if (part.progressMessage) { + toolInvocation.acceptProgress({ message: part.progressMessage }); + } model.acceptResponseProgress(request, toolInvocation); if (part.approval) { toolInvocation.requestConfirmation({ @@ -726,7 +730,7 @@ export async function renderChatWidget(context: ComponentFixtureContext, options listContainer, { currentChatMode: () => ChatModeKind.Agent, - defaultElementHeight: 120, + defaultElementHeight: options.defaultElementHeight ?? 120, styles: { listForeground: 'var(--vscode-foreground)', listBackground, diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/media/generated-image.png b/src/vs/workbench/test/browser/componentFixtures/chat/media/generated-image.png new file mode 100644 index 0000000000000000000000000000000000000000..f460c7ea577fafb40cd74dedf4257552122f641e GIT binary patch literal 1292979 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%0Avb8=0Xp-cuTCOley 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

b^KxCbYF`JZf$v=1eB2^Kl46 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

HGsYHl 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�W6y{4GpX-Ur0WA-vrr>I9OO&;Aw^3CX$k49VF12 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

-wJyOHD$v^WI>>|lpE2=uPz*rjNU^b(>F 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%0AaE6Dl^$shWTFYR+W6l<;5{6e7>K~dZ+cR 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 nm&2+p? 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�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

HZjqUDw)^A_0KY46h%T#p0J|ofL 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

-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

>;1wruuxd=}H*kCy?ZsfH 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

(wgQ`EG! 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

|cES`X_9=^vq5y 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

fq zzKOfjbO+lzN}k5aj_wge^czT^KX#d&7Pw-OFJSGXELQ=Tu`6VzaiQHE?I!{Sx!JA= zpVzOXFgeHxa>M?hebZ&SKGz8Q{f!YZNwsT2RN2EE4K>P`8$?H-AYHjWr_QbZ&1p61 z$kLth8(=SvWIuNRQVXS8u+XtRZpqYBT##E4M@@nHRO~-&;E>f%lJ#GZ$u=9_x|qjS zPd+Fo=b!-JfGF)$9~Tn@Z5Z8;JDZ$t@GA+|GP2CRo4l5KHw>{mOAR2k7S z6hBQV36OJL0C*AK3@jCtnP1u>fF01{oA{e^YL_;V>ikYs_ZYu3o`D!>UOfp5 zNrvFE%kX*megoLIS%%KsB|zPUc)_d65e1QAwcUuv)r@>W4GM;v#dK+u*n!*XOtA>%rq;cUNA(#GD5y9=yIB$MaQhn%6_~fNflF$JDu+(sve? z8YkOK2hdb50k1Rl1QD#iPM)@iPm^H$j7`&57OphuuVx95QRWm-uEXdnX{leBr8#Fq zRw0#dK&94)axR~Ib$C2YuwT^<25Us-7-5P4fxfAQZCVMP_f|e07O^_AFgE&S@3XdNsT>GL8fjLBbU0p?Ppl#eUaAws^4> zi`)5ajKOUuh~cTLV2T6c0{B7nTW@-GFm4UO4NV&^ceLyNBvbt8<4R7)-0Q+yk;`XU ziGJBS7-Chl7~!ph)SA+1a?4^q=%e=QDWwcl_@xJ)?-t+3uk4uH1~VIX5)2#2tdBGy zj9j^QS$XMrHH!);6X+!<_N-%V*yRDN8je#mlUXPG+p-if>CjVRh~iZ33I$?tOKx-9 zvG3vm4#FNnGg}pT?T}s6v-0IcAO=;-wzg(r`m0#O*j+9Q1p>wT+$a9Tp^I1V`h6uK=F`pCreFYG^+tOxwX#@D0}G_XM#2j< zqT}!_VA zeaGycS*U75H$N^nHY;XJR9e!{ji0Sgw?T5#F~@k|*vxB5E7{5>b2wgF*oLRrRg6KP z8@;#2@}eGf>|)*EveY$uY19f{tiALQM)0naPHiIYo|WEa0VyI<6ibse-qV_w4T1}^ z`eo*-NdU)~*gVNP{iqW7m;alxm z*Dd>eVo(5Y)AzWM+cvhL32BHM#twUwuoU;O)jahT0N9ov;N`XAjHTxbHPK&tZ|q zuE1_43%wK*GV%*ke$aPCnPTF$h0*~{Xj&ov2(%$}!1N$lZxI0s{wQG4yar6SdQHH} zsGyLj(d5LGSi*;)T$T$b!baSop-?k3?$kGDq1|^z*-DrL@f7gQs{&JtK%dbA3A$}s z>j12cl=>zir(BMI)t34QcO=*Ujq@31;-Vmw_77iwe0hBXcbJ_X;5NqC2#sybIiD{d z-;N;++rm;v$yCtDum#QU$~i7UJ9j{J!)GH^$J4qq){Y^-Z3AX69F3CqfWRPoW|tcC zJ3j%9JosqNCb=sho}5Rcn~oPUw?eYqE6@(t6(rw;sJA zCxKh9Y>DTt&Wac-_3G#TOKlj@X8J8{aDI)+fXvgQYA^_ova&jrEOMh;okR;u`=vv7 zt!njtK`*}W>0w(FiJDLAu60!CL3cx9QfDt9;|^WK zeWq~=R+I5&bQ2+64O;nnL@zv4Qn|R25xdS0DPP#jg~Uh}Gbe_W=7z9t=Wb8~P6|7< z623Scz{at8!cTd)Ilv8@Grb$8B=tchBrVh<+|W2BH-^4i%9~*t z3v5z??$o=5Y@2D7dvr`?91vQB+lYBagByBQo~GGP8> zSZAoi!0=;#!fPB;n!q5ofjKzLzl}m^+lx`Cyy)4sE*i=jO{V)ARq{42Hm%X=8}cgs z)q-8o@QvXNK?fofU)SsA{`)!_7}`6E0mQ#Hmm)M{JY|C{iYh__bKp8KNyuu2S}MYJ z4coi#Tf7G+-Zm3hzjXVO$XPn2La&_0vRdjbr;WHl zv2ag??jV&+B^=rreQmYZXF-FlFdP;%0XJyQ!#o56o9WXR%}r^!*uUwTy1wlsjq-u@hWoE`*X7mTjPy3{)4}!T$9`Wk zg=|EuzV7rFg4%?qj3Yk0z5V>pk1xKsT&`1iH@J;`j(t8p&ZkH1k2u~Qe{$^azdWv& zIDSD`pmptYQI&Keg83N-UT$a%x@nBTfKM2y{<=ZuTEw4-5_mZe&SPAzdVQYzfhkV6 z=LeLjW<0#cEQ{{GxDoZNq)> zS>o>ZU$?$WWue1kY(nI3Yssp>C665E2RDAnQRT(S;Q~1a-Fp_|R0?l>5)OL|#LMa|$kA$hn#FY1Al-pR zo36%XBUVuhY9w3N*d49nQfy3gadrn86-tDyO_-*}o6N;#<99+EL)5Bsg(nblKySt_ zRkzPB(Otg^aI#y1l5h39VQl76X_DI*X8g>@pOTI-=g{0B*uHMvAk&k3m23^3yD+4( z3yPZEDim&&nsIrHN1i~nJ09Wh5XgOJOKeMxisoHgPpg2;Lx()43)6AVWvk3P7)h!s zfO1u`nT#RLDIw0>8>4dV1TLh&ZS&pUDaaoJZeD+Bb903jO7w6qIP4h@n)^b+Bl_)` zq^=Wmz0dYjpo3}-I{}+HfLj1pUZ89V&BKi-XFy@gk<0?))BI{?KFJ=A7t+ry#4hG@ z(hfPqVBn%T=9E)uWZs797Q|)#(b2IBeqPh0NNs#5o1e$r@GQxN9|@ZaMcq3;Sx;%1 zqhpUSk7FK$L~+|0^$0p_jrw}I9*=r?qYn5ZLphrY2^ZvJ8I9W#CMPoS(gzpAY4cB& zH%hpw6|BY|I*>IB)HfN3vEE6E87438o6DD*J2w{j*(b@qw5WnMomfC1_W@on#mt8v zY-i666BKz2Ts?U}(#{nEFsA>tjdCMdSTK{;i9n>yxRT39aFZK~Z)>a8R({hd9Y6br za)EDxqwT(c0iG``Pu}?6vu6${{?x-5+{Q3nTrBM3tRNdXDPOmjVL*BsFj(HhCEYB> z@}YJe=)u75{>5^8p>OLY=f7|yguBSK{9ZittM0_ux`$wJo1rmFMOmJ4`s&o=Lc`7i z%DDzKupGO9?`YmG4v|}9x^dH)01a=QT#aov`}B6db?vqq?Ar&F2XmG_20a_FHi~&~ zjo4|~eU?J1OJPp@T|#T_afJt?^UN6;JnOl1u13l(I*WZ&uWu5G!STZ%e*HiAAHDmt zfA;A&fBgD#d6S5Z+~yc_f4FY@fn&QKpYh@O>klt;4r#bs*6ev&Uu~Nae03F$!{LV9Qzp2iWU-;@sFR%RR|e#>tXiD zEK-^5l2H}~Qal>FnfwO%J{v>aComumkjIdqjpT(_J?I@CkC!nI9fx<4&QgoF*0!FDE-obV z_k(Syp&Q;vDu04~&^jE_@-f20%0)&}djjSLPn9cl%kS_Qc!C-x{)F_TIb_-HjJh;X z&pFC7VUQJlGd`4R*kb_`$MrftzP!Eh1-2CsM?6)4?SOO~<9aP#GlRJ8Wibw#_`Il269R8MnT*RtM(g^UOf zSL(YgkeCu)UiEyL(sgVH=ZGYOZ8-9#t6tyUUoq`Tq+{AvKnqpm&b0#Nit}!*tv-g5 z#%4HY*|yl9z-S<+Q~?SDADP3Ccip`}Kzjzmx-16?AV?0!`L8pbF@{jLGxlc3?!&8p zI=>pR2h(|KgSKUfBHUgmhd z>T|q(MW>D59EP>CwFMe-9oyjFom4pzgvXe3jU-=USaX}&JQy$+psocmf-DYpAvWVo zH`iic4?FO!k%NkA-R93qf_Y-+9qCj5)22fZn#a6exA8W1ykTy@fkDf@M#};Q$iahy z2R2~(xgiUB2S-pcb$Z-mPlT;~1v2rj1DzI_JzasFNI*|(QttW<^ z#@Kv~GEmVDI#3Aazsu-4ed8vSbc?a>qlfUq6ZhPJ%D(myoI6dUl0;vq%l6DzqsU?` z?xCBO%i&{RDVGS_1wQH1#r_82*Y^=YYZ!*U69Ta=!up!lzGy#|M%>(@x_6y)W7`S| zEX3*mXle{fyuMyFp0~%>Ij`Kf?_=BNHXuxlV{FHx=F8jl?dmn*v*)8)ZzjCFLmsB( zsv(4)_Sl81i3iAntJ-}j5#JOn}qBo8Fp zGLHm@-hbcxp04cR5A?c zF=vWA)Mn%3I)h+Pc{R`xWpP1ZYhZbuWTfdxX4>SJ;1lcs&-cs}GbUzApGgY-wxO>+x%fw#F58b{MN6rLZdp)m$Q8(9smq6)> zDLHI5uBi{G9ld-WPd00!qt}mL9l!VsKl|tYhu7~u9Y6WW4v5r-V?djBjxjdAzP^3= z{^N(|?K+2|o=u9@!^*}mfO;4B&I#LY-xE(dB4;!J5(CrIWVGEw7iuSblxpiLk1^)j`hosIaR*@Z5 zQc%XAfVYpYfBd_@<6r-kfBO2vpM2aVh8~E`Hi;aAo4|P-55MQ#x390y_VDFg2$zzO z9tu`*Au!Xd5YUXoH3Q0O{VocU8B0~OSY#waKLxuT{B7OK+6vjr!Yn$f@?2w!X9?ie zRxr`)y>glJ4hq5C)f5-9EUZ0cHf))6na9s_`|o}0%jbd5=Rx=bo!X3^gA<=&ezoI2 zzC8c^x9h{x!!{7l2>U6KZ7EkZE2{}Vy}S%-RlgISoL)^Y%Cda&Wr*#x(7asV#xMNx zXaCf{`Et49?PcNsAGPgQJaB)cH26A>=VQLRzI?=S*rospX-R7Pv^+4ulp*?mwiK9d zQ#IjfPDK2`Q%?5qmAIIKWm;O$D=3iJNAGh$!(@-~*gX<$-NsKlCB;%v&QSF%6 z4HXg6-r`&d?*Pr^Y=m0)l>v^iN!QDG;_v*Y`JG=put@`uIEbB)2JC4xK79Ug{Z0Mm zf5OXGczBdF4N%~_qFW$rK(w5BlZq^t%4wYD6F?h|f;{-&ccG>7P z+UU5H{KPs6r8HjVnS064h~#?aO7*EwH@w^8fFvg#-KkNu5+`w%7yXwXSWOlda3wZwea5%-Wpq z-I8V3%a=Y7EMFI=g`Vt4ror6T3Gssc`HGJp`1%HH8r$)(V+@ItL(l~5B;I<0HNiUM zJ3aT{YB-xNn*L=QG`mS_))(s>7f`MlGE7B=?t8^lwT+r^`egwR5O7RT^WCE$OjGYD zx*G(C#t`@8;r)Dh;~1}e;Pya_L((yMm8M_^E=n(t4?2L$72rOX+NU*C!n0*%jxKMw z4gRJ7*QVEr9n=b|5H$p>SO*fI7!hCleyx_?@ZNakqDKqDVma6`}%%WG_@V3i&> ziCw%HePw*QwHUXb$kCgnUSIX}u>VuPJiq(J+Yf%Q?PF{M`|ty`4@h%dU-|mVmkZtw zOK~s;&yb)-%K9siW4>#`W!$A79MBoinz2kxN_vkxlj{ z>t@N-L2)Os4TzlkCQEME>T*KR7>5q=IHql>H5!_RTZd+t*(I@2E9#sFG&G?hQ5@2Q zh)LV>4T0nB+aSiTNPu*NdSkceg}PX1S$ zB~rd`ri2yl5Rf2umnP@DY~Rv%|IwEZ;=4mbliTEWY>&dgaiNY)7hT`x+cOSrY8|cy z6?Lv!InXAKX4=OP=8S-#7i0jCg#e{7By3g+Z`@DI-h(7H=aNFdpzmyA+Zzu}caMP9 z04*B@P2vjMVv41^s!7}|SA8^zjTuQL<`Wv0N}Pk_03OD+%`1K^fH+^!H|Cg++AZ&`{n7(-$~p6`SwjP}Y1# z0~i}yMp0!h&AF^^oh#0;+h2>3^~<64tC(Y8fL*q{GNBNDmWzYpX{;QXyxzlh>j6Oy zds^5`cbFJqqy_2yv_D?m(1+cF9w`CqNVyV-%k*xkLzJz#T|F+8@1Uq+aI-^YAtHB( zM$`C7r)l^#osB1!s{Ox@tqGEDlTRpm#c~0O`**VP2!xk8gg69Y#0Dam7flKqph*Xq z2oyU*Vl(SiHBR~&in7RBMw9OlX699&czFaiQhZU-z>VQ zDUAu6GsXZlp*hCn71s-2Uik7d_f3+Yh;tm6(iC-&1G?(%avXYyN6maNH%7KC*3Zsk zUETSdZqVuvp2<^K(Xlw_n-t5HYLb7)z1JBR7uR7KLCa|^?dD|eG@dHUt~qRzy`9b4 zz@6V+5*b$fZm+oC;L@(nV_)vjjV3QGEIx7)zHYsp-B^5!VQ?DY^$`1bAR!#`o|k~1+62rMnul(ma~lid4cr2ZV#7z!?ppNU1G};9CKPN z%EtM)i&K<1L`^M7X&)wgz%>MS-SB@Ihni-RO^o`RK1++6v>?(QBHYy$d_qQnfTE-- z*Grv-cf+(5LJgs=QH61kj@Pf=KK#g|h;wizXqqh=IOaw3e11t7LTfC~HX_1px2^}q zqP9v;SS zO?(`f<{%GDd*)`~7<~T zM1|Ysl(OJUoY(8l<4xD8m*>f;v11Gz8ouQhbX*{9^9nR?sEw*5nbDtLM8KW~9^k|F zNQ|ee9^mouSBS(AH*tU%eAC=PV|k|QG!u&9+s;xi<9*TnMf)Nt?WxWZr=j8O{;G&6BPz|wkziar^*1PiyW{KLVNo06 zdDVLAYDJOd8@PO&x)cEt0|V07Cg}fdyB){syf0-coe0@E4FMrJixs?`YG_y+W50X3 z)D+9dA9uUmJdsJgnR{Rq4$(v!RssVfmg3V8Zv;~hdq5kQhgm9%uOCx(z6ItC!!)Ub z-~cAs;}nTw3%ptVP04gb=L_|6Vz(mBmC60zdVQlf-CQPNMq@9eJKNy&>Qv{p^|v%JU?1K^O3yysM(wi>Mb zQYRv`*rflGsqrN!=GY#<=te9h$C^zO(JQsHZE6;ZOE6b5IJQg(hYe?!GTRByrmx_RHH3{@p)6|JvW#A3wW{D+Yl%Gzo%pXxqkk z!uIj)S%2zR_J8hAU0$D|BNo6XcfNeM(EA`lkh2G?vC_BLEmz*mo}@r(;;KW(K`&#L zuSchNOwD<`y^Y7Id3XWQP|q}-j1j}qoJ@ZkeASiIY`fg8s@L)*40;e{!@QqP5UwO# z*1|;*wDw(VU9wc;^<`E%6K&EA1c+MZ$a{sJPT&Bp*p3UXS4?Og$F8|;0|MrZ)~6*U zt*X}1%MmjlRZ`L}qV2Rrn1JnR|G?}2{%`)rfA96fj{V&mu7esTlbF;6+BCo3jxWd4 zzxGRi^yl7v=k@KQ=B$*6WjjmEf@~*HCPhr?@!k7hd;Rgh|JVML-@IOS?7ZlW2gECN zr7c~d2V5S<&pz*e=6C#i#||CZI6v;ZaGOgK6d_UFVw{}FG+-HSD7QPfSodax6}g(NSnf@;@wRKM}_?h6?ivQpY4TCN-z6x+B6u_{pJYq`ZmF`9SE`&#^x|Qpfl&|Ibgy zZ(hfD#DRGQFTystX*_P^uD5;u#>b~$I)3iIBRuMOHAxM(wE%jRfds6Gw%NYUb8Zjs zwzuEl-~9`Z_{rSAedqzWPCNCX?ZEw{uW^05e*aJD-S6VtD<*r~(P-bScXe9$8Jr1Y z-q~PweDOUh*-_$ad!4DZ=jlQnw5Y45#dNM-2<*MyG5vu?)>qhTw##EW%L)xX{L{C+ zgsuAu3DjI_v3vk2-)I&*sxZ1AAXZ~lr7<|JcD|Rz6&jkf!F4h-dySbji-LvxaZ&k0 z@&f3zEENLrr&=rdhDJ!HA0EHwqi_+GjL?BrV^X$xA?_k%q1!h}hEoO%v7&IR;v}uW zmwbYRrQAldoqkjuIW)rqr!<|hibppv#yYAMRa+LQeI*7f6op_Xqnh-`BCBB}(;ri? zs0gsM5zQ_O4SzbCVf~kDBjOw)SJ@FH7*{j&I{7v~6m^{-p+xy*U$r8yVf9|*WK}D) zhQmh0QPVSos(AOkgNuyjR8tjx^G)c`Yw6`qy zwzkI*=P@S$j=^o4%m}K0IFzir3uhydJ8J-7jS>LKZS3!!=5PJr>;LdS+VBXB3+L3d zLv=t9gZsD~^P{exJ$~!)PyY*V9|27!a)&3nc&{C*8URb+w(Zyb^4I?M%m4HL{eJuG zps+Dk8UzoD7wi}4`T2)G^Lu`2`_m8C%QLRxXsA>u-1&8vbph22Q5vmuV;BgRY?@aA zL4JUZu2#(@e-3s+8i#;fgjDlB-|pR zMp3Ch9-rpm|MA!V&R_nmf3Q7%woPgbyVIavpl}>`dzj;2`lbKYKk~4@zD1=%82ojf z_0jiG%4IVN@$vopuU~)oZ~fi>;=lOt)#r~-xEvSgn4FvwqNE8vY?IgTzFhvm&;INm zeERtptK%8Lp_4sbx?iR1)-zgP!X!4BsZ%x|>@cyMggqT*X+XI;V(NP6vJcC+T7}?f z!?_ogKfhT2UvdF#fE?nqh8x#5+KjTj#eGK)A{!DnOd@z_0AfI$zZ}58;I{iIb$0Vm zKq#uwlG!#4@1SA06G0;N__SYttiS#596$V<`~I!V)I2W00ow_C+rVAAzPx_Lf8<|y z__;rM89R<)wFZe)Y5!WWXRoTlBHpCleYSo4!OLI$U-RWxw}S@ZC&b%3HBN4y^8oo2CfUPv7C3R>0Y# zqFr#@3uH5zZEVSro1dcR<*Sa2%RiF(7wNUEDBZmKlNnJKQisGy7V}IBad3=nkRu;+ zkA$_$gi1v+7@u124?3S=TQ$Q@XDT4-XMu%?%P`xEGWXp_G+2sq(U8WcF|1W}|TYy3o^XR7dw4 z{azfqj6^NrOtfJ(hJ`r974BC$H9MUUbc%p%Zb0V|BZO}<7^NAshbzi0HzP7Y`SHbM zdQB*O@ejA`ckIY<%Mtzz4b1x@+Y&Te`FtZ=dRUFrPZGMPkM$e`!7cnNo&RyO#)e+HeI0k%voLqLi{gAWgn zk58X}{`h{|9uM1KEMoiw2RB|`kB>juC#kDtc`+wA1XWE`i!3p8In`VdbDIOl)4soZ z_qacP`K>Ro?@Fx%?GHoeY$`*=K#J+&h{(@Oc|+d_7_ z5D~-}`@ZSnL0^8Rr+2s>oO7Qh+Xt<9=wZBSzfO&P8x}Dot^B!hknnxTVQBxd6r+az{e z9(V9zpEF}4mb@W3$Om3|)iJRF^dh|q$i=kS4Q3kM@a$bTsEzaCVLaezTt0i+w#Uaq zLp&q^JEMkijG^OlIgUBDIrcCKt(LUe<&tazyptOn*jtXdLUao602HR-5mbgur-AYb35+^lw+J~m~8nK}){jybOD$IA0CKiJSn za@#i=+D>ncgI0<&q)h-h4$Y|}_L{0dj8CGk&znLpL_>fuV`Z3#-htS4C-Rht)ku0$ z%xja)Se4vrd1vHZ{Mp5kJyZ@S#$0jQT4BVxf?I|3y5@pnZpYdMP2?2rOh&iDGUoC-#H_(+ zP4bjgS;6yc*6;loUdIWT^JA@cPDZVl<%EG{aiuS71C_i(cP2V|XJ4fs-C#QBgJBID zVh+?87#tfotRC1fZy`fi+~ugA^Q+tED#OB(OHCf+PeP@e>dtO@+~+V!S?n)}D4dKn zU)8V_&br5f_2f8I#{i(=5n|>enI5fUtUlnesZ59Z^r6&w6<1exl`Qfuot-2y=7B?6rxo+%A~0-TTr_hz39Ft zTeukE5Zm7GYGt}l7FV10Bzz|APvZ?eIPUEuAXVLRZ%*RgXUh$~9tdjZ5!_#6YSSeCKZh(G4o=C+Ub@3-yU z{_sR#0+Z6z0Q_m2!EyQ8>;hza{;GA0Tzu=A_wflkJ~}v44jB-9DjVyC1oCLtu=l%R7J=0|zWYo3~eB>828*CK^vG z8w`q=c;NPa=w<9rvG27Lngj4S$UE951sq9LXH6Epb5ZjMY%ygcy_I?`P_R&=W-&Ta zaJ__Kt745PMR+w*XBBT=mZ1#?Blg|~6|Tw22;CuP|50cGIK+5jl+7~6T zlGp3?2S53(>+9>_$aCP_;BY{i*URPl_I7!ja~?XTCMrnPomU@jd4nf1Kyx0kly~d{ zKq?Ych!fMpOvx7xT>Nk;o%HXD7;atKC|z&}*b#$cU>n?W^Q@jG%PN$0 zsk%;Z5$V$8ZZ(qTDAWp1!(phq87<`MIr}uKD`gc{Sn$x0s>ia;B_B6mdG^}Gfb5pj zF;!SxJAsN?DxafayFUX=(mZ-#vX@_4{|vHZ+$(4p_AY|v@c6ZYS!iLkB*n>AG<+<) zEjc5wc%pIVzT6yyG2`cPs=L3gXx7~3l<2d1u7hQDkaTG4d9D0TJ9g?euc%u?9+p|Q zrjVD_V)sP7*3>#-h4AVg$$B{A?u&N@bZ0W_RV;BKPfi4zMMY>&zgLT=lL8){q)37U zCdeVsVvf`pA}tIl`8KM+1a}wEMvYv(LbZz`?#0vYy6Z?2(Ssw6EcQ<1iIAutnF=D+Qg65c( zH3NC)SuP@`Ak&H>VTCvvo!gs=y>$f)n-*ktu5|O06TXQ}-j&%1s^#ws)}8O?r+zEN z_Xyw^L`}Gs61+25A1@kx)cQ!Yb=F24Qe(_;BqGU194Qh*60Vw4IArz7V5B{RgI&OD zEgN(NoXHlzz!=id#ME_OHy($M*g`fd?#;uSz~N}G$uY_vvfH8)lAY-s3|X{25)U2S zREdv3?UJ6f8)fk1#{yY%(kG-NZ%S|T4Baup8jbZ7(AF1)J;Qw?zFIowpotR6 z0oXQ21He468FOPwRb?)8ld{gly@;AnSWI8h6H}UmwzLlE#%szRr4t5pB@T+v>|JQylS(K!6g#~^!26a$?6fy z&6O!Vj**T)ykRyQClAJuR@ko5ge*qOO9#cY1uNFso%KmIwQ2*gUJXD^?>2R$UEF8v znQdKg{Nf+}{f}Qf9>>)56FDGo!g1zuJYSxF_8JL8t=;On~Qxt&5l-(o?;+Se3G~#r^nHBhHFL#~UZ(Hfm7w*+ht$R*7 z+J8LXwuox`zUZ{HWV#hglb<@1Sht;+*&;gGofbiE`f9otWVbrjZR6Oq4Q^J!mdi+A z9M$NTpzh)cC6vcpx&?KMvd-CXm2I#DNlbJAyQM(P{o0-JWRx8&62x_lB7b zQUNr#1MRi$1Ydv~Ts#s4ABVw+x_)de9Ji z-c1d$hm*`%2zD-lk=dV0Wp(g~8AxGeyJ9eW?gkW{;vRwsHOv7jiF*QrW}0Z#4jynC z3Ds_yqPH_~iW?RCszE8I6yooFF$hE2S$S(s8iWIy5I85M0=^Y;)2~d)T!oKloecAG z2I$5HzAv}O6Gf}U8Pet&Jz(bcU~=Z}yOP&Y=svL)z)Oo|YI7qPO1jWD zuc(Ucu(I`l`(kVxdDQ2F(@t`z4hAUY_ zsECqT}02WlOsgE(B&!67^%opF;A0DprI0h#yCZb6l&c*}8>&NG>pFh4`-a5Ie zsgrRIIjDk$yDzE!g9p`?vq}L<8~O3$*Za3W|3iQFPY;RvK1l#$`dMUiNCTgL=L@`E z|I`2NpZ~T0$6x*O^Uq|@xH`F=I0L{{)X|;fuvX}zY*LUkJI~kgCtPlyZeu`#&PtuK z@;nP2%FDTd;nK$(Mh&wTo8AiZ-H9(j(HKA>cmjk|AiJol!+_&_PBmQhHX1rg+3d(c zj%|y{5>X8xxZw`}sk1#`smDAblHAcCMp!hWFCELkDfny=N1g6>)fme+^}DY_o5(kY z>XyjvK_IX@yry;z5LdHcHgYh$e)g%=VB=Xr5}W|X*hI*Jxoti%!fL+Ju+~`2Uy;HV z%K)sC%~j$e{mt4oNo6h5T%a1c_gi^anU7{3n^a^aNB9%XhLx|-1oYA~)~I;i@4>y17LPpD!gU z$#283`t32mEvm8NmOHEE+b%MmvK(mcfkd)PchUd`iAY zfnOoWAI_ROG=)QKanFU|JX4_kXkAX&`Sj8;=RD>C3;wqOTEb6|Z}uVI1Ip4b%dw`a zA*7(3wQC(ktuXA1(uD-uPuzR+G9M!(&yM$0Xn|u=h;B6Dld0t z7p)<1(RXqY;xt;8L>loF4-V;orsnjOgHtO~=G6eJqoi#vdCF8}F?<+$kUFGmu(5eN z?!g(iC+3Bk)QCVwGq>3b(Z65nG$Kj@`YAThp?G;`xuO&(8`u#wG#wvST&tJvzGaRI zrZuOVZa_C-#!ad+F_C^5=o@7MAZF}95!UGCz2%RokG z@#HbI+A-z*vivrI<9I!&?c1N}<=wXJ9)})7nw#b!acck8_4RUm{rZYy`eOrFyE?r* zt?wOb>gn-m7yr_K@E1S)`iK2tKX5TBJHV0R9M~Sl{?%6>|M0K;^3VR`zwqO)Kg?;R z1`DK|`PsZ6gJB7n^UE*4{K2pO`d|3p{oA-4<6##74038SY1RPtov+WY?|AEsE4sNY8MYrg0n7Iwf-c*Bl#BeF5gK(I1_-OS!weQ=EW9x8C z&1u?m9ETpChW4v;99MA6*eyKQ0kR{2sZ1Xvdlqlvoq>*>JaDF4Mai_Bb`mJB{#8;?u) zpB!P zaOamgYxgeG6cBEaZ9z(j^g_j@C$kUGL3?5z2RNW_JfU9(;JAe(0)t?7bHf&=j|Ys7lfTUfmwk zn6&&9!{^KVb3kuuY;llu+-6kbr8@=7GZmD|`D-Jw6HXA0O9*3b-H;1P<|W--ZssAr zNsp^7bAsdxidsrKbL|CyVQ(w}%&Z0FHWGGEfi$1qT=d^px0WeQySu7Mh^_;R7P5LY zrbIz~{P7h-g;%#gwc}sZhV>|Gxae#V9~S+Z7{jd1xqVlk9Fgk(5o~43OJbM+I~hJ1 z~Pa(zWq!$AMu@eeCsO0)@-SK6@o3&d&xj7 zkm1#T%|2)7!}Qw*JFQoRwpJF*U@io{Bh`Xgfrcp^;>zsQF9eb%UU%pyrY6hjj>;Uc zlFfAFvx5R=EH8f?nCb1yJ2~6C?kl7vi!d#*n4MJx+8tiy7)alVWFHUALnzE}#x)&^ zY@s8Y|1bCvx)@ASQdaQF*vWYWtLnIlEc1!|cVL^1BmA$x=f<%WjL6viO#v2my^al+ zp*O3{^unZN6Q5WMO41ecsyU%)n?wh;g^M3F58bVNMyOO~8Y7Bm^YW1j7{H{tUrzT0n(J=WH0|P9xow}dX=Jd;R4U!N<+;M{PDHr*j-zdu95RCn=7zD%b^@gJte)6Lq{b+E+A;6wgvL{!?Lx40bn3d)yv)QPKr$aifhtu38VPaC!+w=2x|Cj%>U-~1z_v;VO(>J+u?F?nD z*))XFfIG*8jyb>l>|1~9AO7|K;NSXpKK#}X9^St@q=?xncmL{ia5+L-CAs8Wbh@h9Iw824pD4L8!JH-vqaee)Gyg^n9 zEm?IuEvPufr>DnpnYtViQa$+$Zne5|lOh)H)BM~>n%tZQc?17y|40@^+P4k=((-w) z>09bvpUr_UxJjSyCIk_eL>xI*O=V8({+41*MGh%f|0?-}SW~zzccT$DkvZTfD>Okf z!=q#35MjA{pK?;O=F)l5T*1>?BkkQC$ZfJaXs>LBg`>wIn<3+6uXLqm=v<4BLj-VT}Wtw(&tC zS_k?@T&ZXrzOF@Clt#keAXZLYlg(lhkgxu3Vn2pG`B?PcdR`rjRoco?pi8z5F&$^3 z4Si);Pc0_M0_s7U^*QbH82Z?A3@KL?6o}C+Gw!V{I-TW9kv1+17*{o+V!(J9&tEFv4cLWw9|<$iM4LBxOF4Nl|tb9M;sFniCPAFU3hh z#IOX#-exP$Z`8hZ*ZEYJWLc&J|9?3C{ca@cus9#^T0iFx1>E7Jn$KoSC1Z9 zsJRF$QyGzdzm#%Ejlh6Cu-nm;#E8_-iEwUOlBIQ*iVsVK)xu8p?V0S|zUAerWqtmx zo7PWuC0d9_?EA30+Mh)|)HQcF>Yeusnl}uj`UM3P<~}-R`x9hn?Ujq+`s@LZIVX;J z9MfKWlcQ6y->;nLdCSM8%+x1#%UW(LZRdPj2JtPsy(H`G*aujbDqrRjC7a z0GNcyxdb6jcA$6bid#O2U{tsF?Lb(mX&Ymk@Bxmn6E@H4Hrnq3fNJp5pYDwmXGtu4 z(elN)LxUME7b4-#!9;T&^SIz`;-EX-93v!K8svly7@&^~@v&InTx}qzIS4+E zZ9jNIk%e!NmO?I6p-^hj#+&97)0WzbKzYY` zVk{u#SByIMtBAS6S?jQZXk}!ZRjfpcY+HifSDGeE`)ERQpHc5V;b#*>{FSyiBV(7T zmH8b;mJpqn9TVqJVbzx(`u{xk{bw_}p9!GAva4 z4*kXZ9~L{%)_5h6o#*VgHF!Av&KeV98fyc$qKKuVYS5zuEQ+a`s-UZ`7L}nh6ta6*>GTBY?|$&TT)z>spX1V%wb&`DQkN^{VD-bQTpZ^r6`o+( z72qt<@pW9R5Ngb;;;BI!qMLMv8OTM8=7ituYCx^;!h!Cp@A|s7^ z`$`1k@;O>A-CAfqRy1X0@WMo_e#sq{g<-o9;1ep|u4-dw$PB?6@>vaw6Q%8nXlyp0 z)PSt9EEf^cUNSr~d12mLZ!T=6O*#Z{9-3Fkc9fe!O-fh@6L1nFU*H^AF7~fW9GNBn zfIXgi)x1tkOhr(6?aJn6&$7+~I-LKR({To_O3KcvSU&c|$yg6hP4Fspso4a4)7^bL zXT+pvF|BCK9POc26~!Yf!9{S^q2K+wCh3_ZX2K4-b?$Qp+1wq$EE;7rPac2LwDUjh z09L~`atu&pb`>M~zX8?hueS8ADf%2^jIlpFKJ5=r??3;1>_dlysX5(0phzBX z*YT5q0d3SUnQe9@0Y&!}&=FArhsKy=aN8aqAK$MCx;z!+}M`e@v|lol1B z=REHAJK9Xka!NOJS#9WQb8dW-esM#j`!7}lQT5bY zjF;E9%W)+)LePOB2$RsEK~US*Uq8RSJ{%ZRv|RwvU@|O!O^hgEZUR6jQ!V`>P8N|` zX*SGa?xLOH#%Qx5$JQoR)=SFVr3FdTn1-Jah?xsGvo5yCzK6jJ*#aRvubJQ}t2w4i zYXGjh+XTRQ)%DJ4>f+Y^2#8d*jlAWh*OskY3m!%iyN@^GaV9+x!$~~wHj)}67WXyb9${WzOa)o} zn%-g$S~Td%5wr@2rP~x|J6vW?&I8w}V`87pk!r%{#?d#K^rRb?i>mRemq=3P3RRCDN|!GcQO+|9i9l+g5m${N|WUrwu_KB)5obMOA58l0%2b-pWuW2emMqk~v7%O7kZ6(2jgg-) zybO8_G#%?U^7Zko7IsW~vJAYvwA_8Wnrq5>7H)G2ZKuI3R?$>X4m5~c8qU1iGT8>Y zY5fLShv;~YIyNT`k$^pfV_lCf21WF)o)x`gkyiQrFp1|e?P2Q3@5?2gu#!&L{;vS0 zrnr>>Paq6Ct_1foF9CEVYhDloSW(kRVJ@O%N>hNBQ8tYsC=9!suw8(;ZQS=Ne-UD9 zou(VjA&RV0Sk-1_*DJ#lN_9-Fu8?SIoMO$~WvuYN`vteJQ?xwf4O|Nv1N9^B*Tjp7 zpaL>!Pq)?k|;1qeNu@sL(m-Kqzi!YDQzx?p9zXzTMz%ju&HrT_?^KyLkasSTu z=iBA#57`8b5X#-$fQ3umS7c>v5Arl`l7z#_s@O>-4~ix-jaM^l3*`m2k~+RpBxuh_ zkEB)_5XAw8$809^CKi*^6z=?FGhV^V19*C#DrGZMyPPyvUg(?&7{lI z&9Z`n=Fmj28F!_!M$w0VCUpo|pAqlPH2stM{vLGP4g%rS$WS62FTD@G}tNP zXP2-%cm`9>tUAq{F`{#?x71wx2m9BpOBOcguZeUC9*d+WQfFYc`6~9`ggH9%YROiA zxCRjwwcF#^QQHwpV|va$XV!EE2{llur0{B`bhF=IC)zmxx=KcgnL^o7&bPho}a zQv@m!cj1ymfx*cTh2jK(PaBdhWzOwV}*>e zD5q&Nnt4Q+Q7?^-eHBgt-IMX~--(zJnZ}L5J)61WP;bBIWfkc)sX1BQ65ASXQ7?vw z*=G+;W-|NJ9{XJf;!~G4WelfbZK_$|0@S!RS3#fb&zdw@9N^GNs~hnMj1xq+zD~-l zPIn)R|A}!L^_st8et8z{8TI2~=g{qJzytJcC`BP(=*ym6CTWK~`iA~?9Gww&3KX_s z&n0SP&zQinYVBP`;Gv6M45G~XGPAQxbf7*5)fL-EAnMmZ4i0dPu?=p!%+U!V0<{YL zWO6~Il|kdY$f)FODrF;Oes#xY*8mqf+!g9p^RRv$qwSKF1GsA>YQO{v7eD<{&FVb0 zf5>tfupjQ7BdYS#=%$qKGp+`+7%x!S^Z(+AwL=R5s<>m6--@$+OU);qh zPGQD_14B_Q|M0}0{P5lX>;HzI{MLN8JsN5(!0)rQ?a1ygtNT|=FzR+nou*owXm9s3 z1c#6^1R_4Ck?Sj>w5W8KqekLMQ0GyRH4ebBm`W|4BiGG=h!Cae)(}U+gvwDkBl80{L%gVB&9JceLwzl{n** zQeEK&t1j?@T!f!&%i-|nBimzZ*5DgKTd5A@e>weJCN+Et8<pg;b#>EWB8s3KI814<*Xj%u3PwG7yQZcZ?ro6ZY;Yl|Gb#9p55bCVz5riyk zYGIuULa}Uq1B8&&;CvxL%*Mf%UDgpBzoayu)=G|2tDYnC_@TTr3MLvG*}Vs%%_LZo zMcQ2mqU<}&LuqA7O%Hc=5Bj9$!(sxx}O6H zfpmh&n=77M{hD#KRPeb5@`{bIv3Alf-mn|*6_Aq>X}WUCEwOW!#)Qb9`l^A)TNfM0H-XEoS0R*vkQ(9&EmH^pgBaw&|=2;QuqFfo+um_H(2Y&l| z`{R3QUZ!-#kTyxevlMk1dH{a!x3jpOG2Il}YV+EO*Pl-4N@!jw3pM6H@ z*Z|P#dA12n+XiNW;@c~J^wsO@P^_LCbNC0^+nxZPOKGME$z0r8l%fE1GnD(|$gsAf|Pxdg`dK#P}lL-WL!ByOEiBNj2p!%!9N>4SWdC$Np zTLUpUimIoCqMA`>s>TL~R^2l&Hf)1qBmwJkuAkHHIGza*^W;h)_X2k8vFN7#W3ZQp zB>S1@X@Pol9|4eMhu1x|RgWgzeOa3<(z~G%tJ&a47AiSSl_v?LM_^p5qpEs0?k<)o zND1jqV~C_qH`kk{a%aIVLR1-QB~F6$Y<3YDaqh0E{o5p`IRg1xj77K`jy?PTW`SYk z4h&`*gtdmC?7|HKqzl(AVGRsTteix)vjFS&3?QeGCh02&vcxW$XhuMpqmc`S-*0+1 zb3Epyg${>Bb;C%xoRhKH46W-Xs*&i00dncfV&RmU-sIv$g!`D=P2`Ded7H_K*F{wM zWUcPvO3&p)XKLflV2lA?IH&?+uh|85PoQ7J#C<)!Uyasgahj7AYaoOPiVzy}|0hG} zjqo(Kg1sDx)kfwMvmtKAY~$2`jOk-Qn;ouD_z?0`9W0DY^NM8+0^2-YiFA!QWp+h7 zi)%zvDMeu?I~{ZBQsb+zdl|hDbp|5>c)FZ?>ZLx1=(kmgTlk7mmGB8RFH(LYTw60Q zGrX0VVl4|s*H<%fH+^@}U|W5MSzCd*8TBMVVx!g;*2PNclaST?e(6{iEvslY;P$my zUwWxZ?V4Z(;Q9OL+H4~%PULN24Ar&1b-w)>V{i~)?0MSsxT#cF5+xB(!zoe|n#glg zUD^rLfKe7l@tbMWo*c3f?3 zCU9^J?He~e@qxeRA3ffEF<&m22L9<1oJO6qRbXct6ls6pw()QL&GFR_wkPs(J!G}> zBq-Zjaoe_+uXb*i-}i^c`!Dd}qjryar&8YyM}?_cjjKmQ!x3McP$zQ}K5}5cT<3AU zQu4!cM(`1ulhEWNFK@?j-6dG<1XqDkS&j5LxWG8lowy~Sy1<>80jpPpV`?4puv(C_R#uU26 z3%IO_r$>XxdIB66V{F_8_o1u#X4$K|7n%KxP735rxiGD|cfua230X@e2tOXVXc#?w z8_zbLd0U1)_|=xMcU+VB%Zw8S`mBoHH4O*{E#y5WIE5)%0$Evu6>4W}%SRoSL6p45 zYStxvfcqL=HgAnuueP?>a#F?f>S+D(M4hf;2^fvr@n1w~dnuBK_z{D+ zoqj-Fk}=_YoqI`OUATz1*OLx%GJ+>@W4Iy>9})cH<%~8q zG3S6Q78ca^|8BKW4z8gkG1a0XY0UEXlc7C-Sy|!ppT-!s8 zX;OrU0!mBKRIEY(+jNLlw6aOV^S9p|R!a{pfl!E)2Ly_o7|X%-NexpX60}0FO&s7x zUce4j_lmq0`9HPY*2cw;5de`ka|H^W+dvozhG`6P0j36;%T9EkeC3ih{D8(%#^g?5 zcT%r=ue-pL^KHbb8Vy6PP~}9Y>g*&-Sn(q~glL{zM$wB)d=c6|Pu0pZUW&iql;+xEAJaa^3?ZSO9BLhGxTiHyNs&lNy^gP0TsR zb#ja`ZSNw>6walp$B4ALw;ujXY*p0F0XU|<^%?)(fA#o(_}?7A@vC@x|8%(=$Mjv& zL2cv!uP-m#xAZ^#U%dare-zI@IyA=^VWY(?qC%lx5VeD~a2)f?FLil)|L_0XZ-3=4 zeth@Y<3+Zk(*&-7!`rsa*RQ|+-p?KXlmGc=f8givqaR<#M*5~ws(EFH{&}?wEtj62 zh0yY=UMN{d1tz=T$s=+?)G&e(u%~CZP3dsQ!Or;x2fO6;wT@ECmO*w)ev`Po=LO`v z&LSC?-aO0Nz~fF!tD+WlN zYA#0YKa@EDz(8k0wlr)bW;KE^JS2&ot)4CMy9uLt@m@`Tc#*l&h%NScux>y;X|&pRsKoLr_D@3x{gKN)`eYK zWfc(|P6{%OYf1$gkDK%~Evqs~1qnLp^`vMwFDDy$vdf=%F;>3oEg=hb_t_s;D~?+l zLoy=yP;0)PfpWR&qh*_dH`H{J7XKjP+!i`r1_%rSo8pP-P&TbX;5wgiGe5{XSk{iM z42*LBeJ%%k_)nRKfGu7byC|p4TsvLv?}WfA2jJ;*44(a6dS56rv@DI>FaWft)jHl* zh$yge!GMO0HHS46tB%RheWy?U9MDIPYvlC-qa6j#g(6k9n`*hjako|3b!uZ%y{44} zmZ8@$ggB?D^;r`n5U01m!^iS;SH#1(HeKAgd0*CK9N0Bzk7ngGVyWXO80fi+dx}UI zqs-%^u`?TByzTlgfVWi@U1O0N5$5Ae*SsR{bUzDgGj&&T`4gtov9#E!n|5%=t3vmb ze8wGD!g~6r(3CaUSL%}Cp#LrQhAVf6Gr#A@my-#^4ugoF_#+H~6?FALp#veVZCbdWp zmiIjU>!s9b@5(a445nGvC534aZ`MI%9QcWw;4G`e&i|eO;LgzU+D4WjZ$>=h5ZNpU zUC5t35}l!OVT@EosP@c`&~&$$lLKWzr&7ENJ{B3ZQ0yWV-m1~G@ow#c8M2Sp0EVs) zn}BVGk+gtI0>en^^(7mNMkbNrTCA`@kYm^W_HsRr_d9luLxP6lF+c4)->$kozjAKd zAo761wXqa|)vkaZlbrTE_cowyyIvkXUO)S6`|M#_K*z|zIVH?(?3d&D`Q;-H>>e)k z>5S-1oi(0%j8RnDJ1`;nGsC@4Q?wwJMSVt5mhhs*Ks&awFDp7?v$soMjWF%E{CYKE zSvBi^(WxG%l-qEPvXRO#BHt}lsh*z^fnWyNoq0d$nSN1o-qyqtAfW)iBVSh%&? ztiq^f1lwGFz-rSOV~oKuj*S>0uL^QBdlh|q1*4feJKvH`(H49uS_O#NxTkOE88(zL zFT+pAI3vAMzsOEcgf6E*`SMW3>gFsndWffIV;R`0NhCse`5~-J+;BO`Z(pGN*5P-5J?ksm|7PzVZ`F07Nx{-=2Pm6rPd-(->!D zo<;Q2BTeqqQYU3^RVb@U%TWKYB_iWc1Cy)D}omDDG`4k%(M1V^P5+{qB2 zIl=5N?Jnu1njAa*P?YfOZa846QswM!G>@be#hO?L_d=YwKHahg zy+b@txW3pf-TD6B>yCvqvap1sMIqE&_2g3iaWOx@up7i^H)|2jjlDc`zzbUJm2PkoA1)DI?LH7TD%>8 z{{9Iut}t(ehC^oV=8Tst%Nb&e`YHVtGxDGeW!Xi0YHix$wLMTwFfee8VQ=c7v2ZA~ zj^H$qv~E>G;6{94Fm|g>!aLxOjT|tLV_)=ikS;G^pqX0h+y}qth98DcaWF5$sYQ&tf1>oL=Ah9f<=)zTl9ta$UW0`8gCHxbrM)v@T=_ zBRukziilZ6Xtwk>UB6|}of>ysisnFs@VCs%63A4H>W-0Uk#XJc1YOUYlD{(qa`mbShdGk?mF(baPHj0{j<3qD32$f%J zYY+=?$wHvzI#xYFwI0m=j*@sU0hV0D3yZaPN!knf14sr7* z`E0D`#VN25_Jkibcj8otOAO5$4$Th*=si{{2cb(zx3#-}g)8YClhieUV zvbDm>`lp0pnP?ZguAd%65}T)FuQ4Kd#apn5$%b6w$Z$g%0jxA@ma9nL?DGI=PsW-o zg+BLSplg8bQ5?EFVl{ATl9g9o;X(^#YD}fV%-Er0EoJo!=uOn!F+`vq0$-%JZgirz zuKKG(R@u_9L3wS3B%6h3Mjh3D({ksqI#pMmv_NQ%Hv*$fQo2{UP_SxMlO7eEjf`If z^U_!ZFiN15yMD<}S=U&q4M=#LdH4yjW8XQVbRht9rFYCdlr+YkY8WxNl&Cf@Td|u{ zF~;yMSu@66y42s97dYMt5HZE+Tgdt8%<2SJzbUaPq_aC?EJFkhN!yM=-ywL$ zzM^hDrAZvvMA(r}J2~ul7@T0P#74;@oqq8^wS~h+_)nDOK7Ds?V}xz&&Y^7wCCmnP zS!j|AnvjQ{LrN*&i4!Qh+S$Q`k(9pdFp1{>k_HtD5bkKbqC-gK&&Y7mw! znlG3Ty1-}PD{+6xnl||6j+%B+knJiq1}-SjUuij-M|6gtj*C78H4~+!_hMiTr%RK4 z5#6q>vY%XjHjUjJR)8e*j=As;AZQFK(gzvttX7tAcB5-nqbTQV+8NGkBc&QwmVpcH z(wuw@&{rPR)4GX;w4$@9R4d5%s+6y-nzr~>urvVsOZX?LaL_?%OrJK$$c*^4w za|9z8a6m`E32Pm`j36+qWUZ@xA~OiCWAhys0s~fe+39S?W$my}JdVtO2NulPpJ(i_ zrU$LW@FAaLT92a$Wz~hE-*|Ti6QRf&TVjTh}`V4rN(kd8n#@d9pf77o?M^{ zqI=*?^Mg|*FvC_d1D^OGSOu#BG%dZssUbVuzRDaypE+X62Em>Q%fY;bRlnV4loL2R{h0E2klB zJBCXA>i8~UeX)nD{MW3~5Yb$baMiiE50s{91mo<=NfH$xY*^LVyY!ia;c7)JZMIGB zgZtoZQi}H3{gL7O1ITx)Sc+Ae(K-v+EoP}X*Q|TkR-dunXbf2g*4@L7HVzLbEM6mk zT>dW6Sz=8tX(^|vp`vVDjN@JhF@nY!H@0ody2a(naDh?Ud$6Y=8sxQ_#(GpQh3bx` zyAUYC0x+zaQ*(lmVx4o&j5sp>(CR-1xy8Siz)0(=KihPoMY2FeY8I!`E9r_p%)0v{ z`v5qao$boq1-;ev29eKWS#IIYZ#}!97%F_Igzc_mN*;pd?ib@p4{Lu-ySY(Q7=^~6 zS{3H}6Ox;R!vMW3!CLs71BK2Q*(SFLXvBq2$`F%X8i`MC)zUw{lSgTiC)}2jv;mlL zOs}l!6_?SoT@JE<(`$_*trr88`+-&0&g4CFByKl6ZQ?Pm;LD&GPsuEI2Ps^DARY0o0>UKdhAHzxo z6(}XT6_%X2aqB}gDiUEWdS%LK6wz=dV|ml6%MKmuQw-{k zSjAw3dqQVFee$jx@249-3*IfGy@eWovwrcXBtH}+bl{AiEH`Mie>bD!S?>B$nB<_q zF~$?eKDNQJam$35%YgeU2V)rvRhD(_?8d63=!UhjGZF^J1IHHbY68MMrX<_4O9IG^ zz_1gK83X8z-y!$vVni^e#&{w|BY!Nvm)PF|y$TUf78eSL$8FB!@4$I?h$k>!EgB=z zdlxr;8fceYcJ&Szxz~*AP}ue*yu4hLEtT%OJ!}MaW9{Z0CP8+HG>Gv4n5#}r-xOK! z6TYT6H7AEfnQn3pJVF;UVs>F?BcbT?ht$o0>&2eO>gK~(wxd3bU ztCK5IQUzoyo}N@02&E6wYw@6#i_M+mvh+j@#@8}Ml=_n%ZT!>jFL6%YY{HjVyEfSa zp9sex29vAhennEgdj|=dCvzLq@y?nKhL)`kQW@@`W7_II_U;?GNKDVI9xmKRR9ZF! zj9jOkbDmYj0k$Vnmkx!PC+!q?PftXXYlVP<lMiqMoh!+V)u~*6WtJuj9xbOWzuhsq-cm#`UozElj4ueL`Cv*C+NCnvWGQ z(!Gk@w~4?!;CX$QoP2E@`xsvBhm2><)n2MKE_f|nvxqhrn%}&^+Y5iv+V+Q!SjC;v zNdLaTKSj5*CQvb{4Tvj$>$TuXAxA4>Y@mX4WqeVf&}qNXi+AohAE$nn7)p2$i=?%Y zbx936pIwNX8Q$A5xZDbxhT2yiQV6Q08ZrV5t?l3fWB4mF2?&ocjk20t-YBbrrZKB% zGUEy99al)UciZuuaE7b>9Dyzua`w4)U}#pGiJy(yYV~`CUp0AhT1=(Oix3jzvRqOu zGpgx*hZU0$)#la5dAzZNOO-QO4>eJ<1>V3;Y~;RiY&5j?0)-lZhwLVGEIIgP$gcj1 zz|bNbrH>>ij=_D`*gT9vz~l3|PzEF%)BPB$`cFA(O=l=5b$c-`$c$l7q#!i-$$Y@2 zF)Tcs7z}!=R{|DE&aaSq$-_W+z+Tt)tfOu@WNQ zvENc(_wklD7Lwc7SuvlAFqfnw`LHbg7*CS)LHB{z8X||_Qhhcqc0)iA1E$BY=U2CF zP_PflAwn7WAm?>rTAf{!kf7H}IJp40N;-xhk6Rc6W0M7Ya+JOxw<>#z^i8KjRJhXD zm%R_bH$3qp>9R)mLacf{P_qbj>5Xv>p4^k$yf>@~+E;CIT)xj+8*r>p z%?5hjSFSyDe@uktj6jcESZd*l98vY%V5E4PQV_H2SRsHT>beF+U~J$Jq4uN&Jd18? z*G&_e)4jt}IwtVYnlBfJf!}V;+-u{$W7}}uK-=Z!sEfi8k?8xL_*inGzxcfOB35cO z+{HU4&tfeVGQ=!+&Dn$;HNDUN7%8uI{d)A}EgIdtS8sy-w|ls?jw|Iazr535s(xeu zVk-VF=xLVYjLAK6gyOG@v(}*n_GNr|k)dIHNv=peYEaCV#PfKN07$LlrUE; zp;kG*VA49L{NpDS=S*s}c~~eCV{|ssy7R7P+f676$9y_Wz4Z8Jh}C^Q*4?ofVGd>q zqO4K#FmGl1x^vG}bjzh)VSF7^QZy!)(zmdf%xZ#^_FFM%>jtkbwnBMnnqiG&5~EFz zqYWb#%{Qq2OH8y(olzkiV=X!ian7oxB8PCK5o}W7ws`{J;jZ19Mob>6*_=LOvxP}d zg@gPlo-fur=Tc1p?La@o&m7QUKCt1MM{mOoF2Ps|AJNjgr=b{bHkrs_h3FQv8WD}g z`#OoA@=`5zzTws7J5MM5rh9lcjW?z;P3|`!Rlm5#d;a#OX|7V2muER+i*d&tj4RSY zohF1>)7?zXx$erB2z=Kv#^?muA>FwqFa;AwJ|K|*nP^%dr-aDfSqZCuw&_mo!oFkQ zx$i?>#He{7-6(Ul9>@XkdcZf!KT*(SGQl;4(@0}Caf}_~ad7V32Q|jn2E$F4CIks% zV&m8)9#`n_a?Kp}%7B^`{r@$h)f;G!lw|m8q5Pd!MrjtlHqH0U0h(K)b@FRC@SazA z?vu&KWtt1h_9v!J_~$eR(ZHVo8xci6$|V;*DqnB)uUKP>WEhJiGhb; zmm9oxbFW`p;e_gvW8cQUjcwodDRS5Yr&9wU4^3)}ZQCby>X?|h|7s)eJz^v?P4y&u ztOb_O9fM=%(3ta}A7Nupybv4bfw5t1miEe`>KpRhC*6JQkRL>2vh9dgF2c=y%zs2? zIeWJ!@h{`?Ty%!^L9sWk(}z3Ke16lR(-o!{s{S;2l(0rtydl-{G;Mo2+Dq^L?zdqa z6*V^=QIsrXzhUxW8{0+>2*eg?QeP$j@XXjDEeL=63W#0&@MWAF+#qhi9NWH+Z6BCJ z7`c0^@x%_T7p?;bj>#!6mS)j;UC*fx0KZ)=BP7-rU$cs16;>vYbUCLn#4b;;>T`Ri)8o1nGjq z0n+zG%!fr+S}w@9Xv#Wc%GRj1=acE#5$?4=AxgnFKeG)NBjRqQOtA~v*~tiObBdjp z>xD;N%2CLBnh87d+)AtGqAh+-!Zv4HCaEAlJoI``8tj+z?^hNT7$Koe#>)`eG)4{^%Pw?$-v!Udqp z9ZnZ^+}g^MhXC2e6zC2`^AwC~mK}))#g>j224a;p;$Ca~?P(ab7Mjm`}&+D;__O=S$#aqr2pHjQaBbtoB%f+1g?5{e)++65R>2m*pYws zLQJS4gC~5||5VgBOsp-qP%JqM5w6~X6<$nPP z8F5=}eu@JT@v?xM2qJW~U`-AQnm5(KO$e0drR5DiqbPNDHOb-|tJ_}0k}er4A}y1p zmTWZYpn@>wXe)+_Q@0kPmoj+1T8+P4K7tT!Yw^%2dF(RioHGlc=@Y7W2^fWv(+87+ z(=0HiaDpilZu5m$g{~Ib_oJAB4tPDeJ zVcf`ZT7fhF?-NN~0R+p4xrL9TkeSSArk&_9n`W4Dpxt`b99w^tq98hJ)h?}-;@-L_ z9Ily1)MKR~QaIJWI_dNYFtW{9#5QK1?!JdUp%BYepU^So)QwEfF`GpDB%S_d0H!0o9UJ9I0IZX z_h0^lUwrv~Zua0}jw*8;}x+gBT7BR10jEDN=b;(Hx zPwf!hu&$StA+taCvjH8cZhQcRc`yzTIH!6LVJnrxP(zN0#L2*v{t*BM1x?I<=6gT? z-OoQe#=aew!QJ9Wgq)Z`aqKyXbol?=HthS)S4ns%`(BUS3k+7NpzfvPG$r z4kie^a$c?64;ONgc3U?Jq$m8iDz3ISo)?K+Mj49)8?$Nc`E+`jyEXq%JJp?QU4;2490 z*p3g^FaMy95B}U$Sh=oiMA1<6nl#$xk+CGV$O{g<>1lrP`zM|>zR(nOT`Z^U%*^M9 zckeN`arqj$Bt~}dTXDil(^S^4Qz8)9w;M=cqbbHI;fEQC*NRYRtZ~|GBl_x03IccRYk$Mi zz#d+XHCHMalBbCCk$k(uW~VZ8YnRnbjDDfw;c(9BPv(2+psx=WBk7BI2bvavKmd(5 z8X|={Ua*mPrEM)?F@=JvfMMp$(}E8s-JD7^EW)$9+q&^wS)sZ^z<=X z=%^q(#Z$$n`_NYAnCuCX`m>Yi5T+# zV^OYfq9O+q3P4-TL}6Qkq!2Mbd(LKwmblyJHq6j7g5-V*o?ms2Rr|06!@|(_X0bfn zap;J7G)v)9pT(~pK%yUJ9BT*pW)Z>y7Fwkx3DXqB=W`Df&!4#RCuxWdwx^CiZE_3K z@5)mD`HYw5f!M@P=o*&6=`BKgyR~1^;qbtkA<=!OG{|8+X#kos(y{ka|L;$ z?}P+a>#h5^T&K(WTmsDVt+(WX%b1dn#iX6FpBxZ_EB`7!%HlgY1bJn_H?XZ^?-|GX zkdp;QL$&+e3RPoq{aVq`?6b0m&NMNLc_;|S_4>@W{TF}t&;1+!#yf7;u^kjOdCYP6 zqgH_SDe8FJAD^FJrVcwEVJSMVp~{6Pu+hHo$&hcy>v(+l?w|SRA12-&x9i3Yv}6Dv zVdBtso!c>ouFpE2KRgeJ+&r|yK3jIKXYVSB@zs8#HzV>NND($E80wmF*0P*fUK*L* zcxbKh{A@8=LZRo&*Oy4ddbZY)t{mT{u6f@`#1l{Kl?^LjPa@_y={YXK!~j?S+bFR5?2Yu{3p6`$+k){$7QbDX zZie2&HSpqB8{Y}Eb0{P3T#T)cF_HSj(4~bi3-{t6O+1n;rE(SYR;ZO1Mo1!#R5VpY z&qkFAk;Qf~)I1iAc7F(@%d>x+n)7nO(|7kj@~=_{fN>ns1P=Hkq*gSB!PkpEKQ32H zjZv*CM^V|gB()24F-QV5!RzZsZr^(N!~f$QhxGmsAoJE-d?*md;2YoGuJ3-=@%B74 zS!Yx>%BOt$vJZ_LT@s7Zb#s`<#G1qg8*PBKqeYf#BuM7J4#^BJaOu=ur6dhI9H%*;4vRp=nvS)7+>rdU!0 znKM6_^r_DwMiSn=k{Th*Cm#V}TSPDMjt!OkLu~ZfvB$WF!C~9ft-LO5EM##HdMJ$Z zgoG&_g71MgeM%YL7-J|N3j*zlp~H(iC4r;Dizz-eSNLNnM7U*cC|Wc>y)r4m9fM+?6=#mS|Hr#5e7-<7A_u2t1ek@!~cRDNDH*zRm*^1vJ#Kl}LE!`S_p1&#@r zKgAt7=F}8~C%nGCUZ+gt`beVi{N|N1*sSMqTp#xF`@j6&;P4Y{1VD2hvNBN%%#LwL z*X!jtE(jPt6Tg=KwZWU)E+C?BLYXrxDIQ?10*o-)3z`Imo;eq*GL?WWx_|FgLf#12 z&1kL6>RN??ZZZ`frcc@zkZVW^Wi;;R08D%%6%^wO&q`?JHPj`Jsxw2 zpeD_I5&=K`@Su+QcD%mnI8qXN9=DOKAvVs`&c$ouK$t$)kN~A-X;tKSMzx(uuxHHk>uR@48~+G@FZNoMDWYw2$Ts<=p> zUylH?z6KzF`Yf|HBy%U=@+!H8KW@h8Es4#P^9kyX}-D+J2|EYItQj? zr(M$PI~W12OagbVE^O?7n~bbP_3f~OCvHrA&?RFipkZ+%T2Yp6Ddjqr3r>V@eVZK*$S`#*^PehmYOLnslr=GQBWoM;_XvUsUed*@ZLcfw(vO2F31EVpc)Pq@evH_XcT7UUoS0Fr7zZ^B zw~x_Udd?mOtn^DkRVb28Wr*`SFVD}@5>mEqkgPAwi8(18b6y1!4`UOk**loA%Kx&#QwS!pX{lApRj^|!r|)!r6S%)+_+4%2zefWe<09Ugn>L`^U)>IL4rQl_^P#+q`O*WN1_d$Pg0iAmdY`@Djux#| zXOaN%IOfaS+bgcIQC68#9KxK0=A8a`jKyD?0Z&OdscE0KQ%w*t0ZhHTUF-;w_(8`p zLq{~GG!?mA1cED6g`rC=Mc)j2fn(Twge*kgh=*8jEVULyP62yt6Se2_5zK1#1?g-QdG4c}Z)t zQYA#Wz(LjIJv!F$>L<&XhuW6EeD)CAv_5I*`}2t&>O2*N1Jb@bEh3gK7#Gm% z^=(^JdN-wwzIW*yUP~dto1)WoNh{^&CDw#^^#fJsy+6?Whb z)9=`$&aNA!IZdDUZ~SzVDL^Os^ycn|@amG)Zr+2tPYtWhV({E=VFtjj=gQ<4g@FW8y-zyLWg*`GF2mj zZ^9Q-jF{3f;<+@%^M2)Igxz0=oHMv}LCmEs?%k+BN_*o@eRs>UpThSh%#jewpPQnc zVltVWD$HFL z42Wuk6a`g=rp=fOflMY!V%?I2sSBSmtCv23O$=?Tuvc!!u)flCmcFqpDsktY6xiNz z1t?_|2Pa8hwB3_jL9oT#GNUwz!(05u!8xI;B``Q5>Ll1HSLFnF9M@x?jAo6eR8TXf zQUSL%W>}5fF&XC4ETg{<(TKWDW}VVXm8d^!*5BRMD!B4nn3P)Sdb4B&GLzP$mG%pf z3c3oront$^E2h_xLErNo*}r2v`&Z@G+*p7ZlCr8Nq|r;yR0L_S1%8wi!5Wlph<`Ra zDk`Mxo(HWq?&>!l-?9J25DW}VWMU!_NT7qRnVHi#n9~lbg&A?}Jxw)zz$p&m(A3o- zz#h?r>|8_Je#3bta2sKO(Q`@J_Bo=k;`wnZsEDXb$&U!wE5tJqIpl!}w=mHkAlvB$ zwtZx5PZi+-uG4m4Z1eXhR0b(6mWmA$rmoYL3v$-hD%tmx!9jA2E!wR17dT_5ahbzz zxsd>j_8E)&5~3K7{vJdxuyL-jjVg_5CS^0}>1Nr^?Bez?NIq=aw(s?P zX`sd+`aVknCNY+=aRJe~)z_<;VdEI&CTdE<&j<`|!KY1r(-eAXt?89)xS4uMnk@!Iq zH{XM=jE`^J-A9_UW&8S!F>)R+Ap#Vpv|Cn>V6j^LyZn|#-~i(DY|mfxm2qKaBa*rv z)K_hQZhW}jlp~C<_wx`KwwTL(X;BskNG6|WaKi0R{aS$7xN-J>&*MKe7Iz_I=j~eF z>3=oJ?VX#^)pC1vQ+%UF`|xL~0W`|S3Ks3l6El7r&}SW_^^Gcr$r9%?HEZ%MN&Jbf zl2+x2_)G`kIRKxAByQNms%l-`PqpNQ<;m9HSG|l|!dj1vn#6Eqn^%SY4< zgMH%^iClALrNaGKC)?d^51LL%6Emjmq*%Gv{g#D{1{N$on6dYTofUF7GQkr;(7SHG zg&1|~=L1=__pZP<5HI^IC=NHcV+PNoJT+Ap-|O(stKjMOhiI8mafaltbKb6Ov8&F<8H2H<-E`-CWK*^(9WJc_AFi8+KT-wNQm{%=krm>G6Om-s2(rtM>20yiJ~HS_*wa0dIDn0shwqqL+$VssMV1zu9k1>PS6i>d-0sG9 zBLTLu%DHOvn2M)xl=ZQ>?k={hL4=x>lQU~yoLB6Iq;jOI#M~hk&T^1sYZ3OJ{#uJz z@BXNQn)53jnK^%X7fwIf4bmdtk=k}sfp0vz0ATS^ZUYexzukD~#Nej7E7dw>(%+`?yH_cDZq+>K5wIQM87qkp4I8M5 zRdm<}fZ~AeNRQf;CkG^3iixu>?XT!2B8s|iNs*28Up*{#FVb|rGQJH_=@%{+pNFVB z&$9OPST{?n`_feSW+2g13*p6=QN2{$*RpTg*)aE&B7uT&;J`kK1}g6QWoxk1j_QXVy{g)Fe7f`V7b-Du~a&f#-&`76QWa+#`Xh-zcXTvb8o_R_EUoI(1VeyNyPSur% zLaAi4J4NL9RlxLj#hTD^#h{8YL1Q*jDOT4#)L9*~lk}t_(Nr-K@x7np31R>v5SXd^ z39Y$$jSg1Zm}4sRtL}mrAuI)B0lA@(bdp<7bfP=@-C4SyUP>Mh(EgoXB;7G`(Ss(_ zi`id9C)70e{GtR7C=x5_sR|x0ay<~x9#q5skFO0>yfT1 z*;R5?;gXCcTgYG=Fh~d?EQ~DZxkHZ@mK%r`eR|L`5)zVSH;s_&27-nSHW-6deo$69 z@uO^$<*Zk_A~WJf#JzFP8 z&MIK3*ejh=QD1Y?6egD=SuYXu_Q68MJXX#lT8iOHwCxsNzAjOl04-jh9eBCByoBvd zIH_7RAYNP8LLpZMwtIBsagp;V+2sk$avh0&{iAL*w9ki+0N^+GS=DScgtTQS^z|eL zb%Al8$*XJbP;P0#mPyYnD+Sc z<;&yK7sPujG3v4QRR041Fh{2R%s4QJ!)SBPFR$PK>O0N{vpy99v2h&7IOjCrSP2`+ zqqM)wu~9M(nJQ*sbK|b7Ox&@QIwb8A&?QSotXg3ar-o#vtm^+ zQ8ffW7WdxxI3^V27u2RNeEaq=XX$*KzG_%3liLjhw}as@+L7XW^V5BHys)~QQ#cdUT6$(LJ8no-5Ql9hL1~gXpR{gK|)Hi^5 z%RLIhd!u5VqV{@C*r!muUY|C(W)q_q;RdUywSoE0AX(@B@EKQ*q&+5YQU2q|(7fs!j1ySY8_GN*S z!OYgaXsZK|vDMDTUsh|mG{X9Y^`9BHQG)CserB$88F}=y^j&v_YpGS_mGDZx(+;z?DCAY@l2e@|HQf+mL3@a3u zxN*yqg=1m$iY8tYKC`G^x0RoC?`S|y7T4{ToupPY?DNaTrMM*&qt$IaO2W&?Bn z_>cXeKl&&C#D{kuUU^Q=IeGHL$;lJ2VaG5#F^~7R&tE>1^Ta%0QCJAEmnjoW{4tdS zVRMr9`t=|CvE6apkCO&+t_S(n(nmWEGrWF#`*5Fp9pNE!O2(1}OhB)(K<9oj+1e2` zCW)JbdB|i1EO#84Knw9_465>^Q`IC?a zLg!K2H?=WUxDZdjcv)8FW!q8>B|G_fK?m2DOHth^A6WY>*)kXo5`z};L8Q@=JXs5p z6XPm-VH;Iv3Ilgo8pRM`lg~apL_%1As$Z>akW_w8npn68|CgjT$L73a4V`xq#x3qY z*z!ypqGisoPTw3Q+iY9AP-qBS)WJHJ&CJLVJBOIR+)L-~7c+|a#bpgoau#ojOo2!2 zA6-UIKk9g%_bgpJy`blrx_Qf0e+x&}V$&5EPLEhfjA0D;bddFJj6|@Ev6y0<^o1p< zJ-Nhf*rh0iD8l=C2G5iuPXdhVw`ZTm)E>9EGMFX4qnilD>VZWvC6XAY9C>(f(S zU76N$!t`=%UYW6CKd1OSZOoO3Ho=^70817Zgwamh8GCk628l%IrfTXP%Uf*yUfVQV zHO65l?M|GC6h|@&qCW2BWrpL0uPgPEAhkk?!md!AmN@eR*_@|Bn9)tN-W5BV_6ewt z*e9bDzhIshaHPsga5GrjFh+|Zi!w|2PAgi|1=fM3X{%UKQ0=ut&+_IJxim2}U#BjW ziGKiJd?9)hD1*@JXY*2_6=F2om7-hdIJ`s26HvV>oqD=(EhLSjrdWGGKsxFZ~}$N37(|K30TKlt*G{&!#A-5v)|@|^RW3lm7gVYd(CPV(zN`fI=U%}*bX zX~RHHFTs1VT$Adk^peMjD<+}gr@#E!Z~m!2{y+Mk{}XdQ&M_xQj6<^YQLi;l_+%CaZkM`wo zyOQZe^U@{asyOiCN~}l;aaVWg!z!|8jcT(w7oLKCYUZM?q@z4l=B9I^_#~~Xp zn>0+*rjtFJmp7lrg>TYiO6aO}y;qm%e|$_yaOt2+N9J6DIrSF6&C}$^Ns<7^i3%67 z9)sDQM^x$gwB{gui=R~SCHz<|xb%BQuPWfBxq$C+9g_v`YUyrh;R(>}G&>hlNpKp6 zC|PpPN*4gwW$=qZvw)(34}-a$JVT@{lIRq0{d5V52MFb~yw(R~Hh99zfDn8MNi(@n zBDM6+y9dc3<@C@jb;%Ix(5%{Ro(2wK3n|u^5E^y{@s7><^2l{IE=QoB%el`oTm{JgNAc{^UI(8^84>@$1z}B^Ho$} zLM7L$xLac2wQBrch%JAiEk{Slvinv zLK{g`XXTbe$pxzsh?)NA2R=3J%?1(xnyLvg`G8mU=r=Bo=$axIlbe;!5GuW*t-CL6 zya=Ucafx+NciQC4i3fm*6X!4hW)qJSkLkX&70MD4 zVv&9gvAh*VpogVm(|ArASLS4bSc~-Mxi(Op6EnAlNEDB|yPUVb7alWvCQP9+@vqv; zuwC1YtWEN^I&#IZbZU6|Vd#ptUXD6#OSC(kvlC}+%3X5`A!P4Q9OrIq zWjS^F=_nIo42EHIjEP7A5-^+-wBfJmUS{drqpj*mRS_f?q?|*=0O6Ru%3`IsHKa}c zqI)c&!n<0^qjNIG-Gi{J0hWrdxkqq=)5}F`m;xRk0DX$RnB~vpag!msdc0u}Z`sMl*Nd*o z&HY>6vUk*NU82V~I}gGYs8*C=S^QZKPR?`H@yK)95zDN@otpxO$(Xx)&Muk2>;bD} zGxw>ucvTtBlkuZ#yi96iuqE|2eyEU|O{^JHM$egWsOL@ACipR<8Q@$!lfJ$kYwHu& zO9B|iIX9U!!ee-b;%*q;dLxCI9ZPl3=*rgK*eyj`qf0?Q=bP|Ad9(BCtvl1_U3zZ# zN;$g5WFA#a8#Fs?u07BV({KU8tuOa~3*9BkkU5!I%{_ zHX}Df!vv=p2ObAMTc%ssIMBu(CK6+>znVU~dYPvmsoh}f&OFog2jZPX0g+Q)29)g6 zm^c@cY3N@WV4Y>l7&51sIg1m3;XLQhzyH=gMH+yZTrZIGw=I#L7y#$XxJSAE5*tK7 z0P#uc;Q@|ebI$KSefs$66Lmgcs{s5pv13(oYvT*a_BFQE%CKf)PYde7+*4g78fS9| zJI2HyDha%$+|!}UV(G`)8KSZUBa?(dg-!|Oi7!1c>QXUZ&Aa16u1G1i8d%XZJ0mOK!!f;k_j zy`Eld=rCHuwK~o^yzFB|FgG5zF;AGym7Vgn2nO;*_-5lUKEOFoa7L1%%5-Z$BIaF4 zn@5+gnG$o!tpxLPWw{tU9DoD=EU>)FCH7P@+swe~V8#*`PZdZaFi;xlvs8mtOzoCR zDtMGDBg2T3M}abRT>BNp#^1WgNiJz7$XxaSmEJTvfkYukx(SQ#bo&IJ62ed|`&!7N;c1oP4q_pqXbS`AX;lU>+jnORuCQUGIef*FKW*b+NPEav63 z4dUjR!YOgpo->l>i?w7`e8&2rhR=HKx!I3Ma!tmi#4MgPg zn(gHQU67bxJXKn}DOFgZMTiv8+-|lUtO?8BQ7W!cv={kjDk!$VD6k^HK^o`znCF~p zh46YC?{Th8Lng>!c8oESPYVTP_UyV@be8HFQ7?81nxs)z%CCv+$x^ zLg*@qNxYW@S-MvvEI;^Ek#q zrY3Ril{{)oI2tkN_OSqG^)*6W25p=?IC;#$^B|}1V5Oq057!%UO4WmJq0_MIO{qxB z+P9oSNmL=N0+{Q{w(L7Z=*P#)9Jfg`90u^!;IKDIuGy=N8%17J`DVhgq^iac09SK> zz)ao9-I-y5aXL=*GvRubJ@xa=T#5-BKZJE4Y?=c(GFb3yz{AE_20H5cM z07FX+MAj?g0OBnUL7%Bk#A=iE|7m8PVPS&9g6?U6*?N=h1M`dV;fwhuBLxoSHenNX zEIBO>_n6|MoQ3rHv33K*2`+qyDuHIJMV-~fN0^pg)bCim#&rvPENmgYD_FV}H3n7P zqB{(W$d_ZqK!+K=eEGaw9u4FqSLZx^F@_m2%#LxGzhWuEDh2rb&jbxSAtVOID_h=E~CL&W99?$u8r&`m;;HOijUQ}P9MM7i7*a{>n5XE0=YJgP^h?T$MwOjO*gGT$>%1Os|CG z*g9b)45uDQHP5~g?n!+k%XfwPs)W5C73i(|K^2p%gb{VX_83+bQx;;NBm~8CXe(o!y}sfilBv%sDwv!@z(?Nseh@muaKv-RMbX zP8#Q$7Lj(o&iU!Fp1aw@9SeS#ogfbzKl*CCyjwvn&CCXD^?!JGj|YDIe*X05^JW%P zj0t(4UL<8oK1;ne4Kc*(f-O`r1JpR*T+4?PW=A~kdbbELwVTVQ;#1EFTrb+(u6e#g z*L?;;_{1%aC#9-qbmn-egsK}3%QR^pFe&1{v$jTqd!)aIw*O0 z3Nf94789XOQrw&^|!nL4X9-1YwZq<4AJ2S3lptNA|4o?UZehzab9 zNU@^%IYF8Sr)x3^3`~w!-p|J;mIrcD6~G5^ob#*Oc>n(8#sT6SFbqDUB*b>J0*-RR(JMWV{I|@|JRSeQuu+z za>j@nS7Ts-U8xQxBi7h3$>ka%VPmWdZ7gL5!AUMo&b69l1zzPQb2?)_+Hvwn+up*Z#vLJ5ZZX&a8^hw)6P<^);oZOkhIoKi z`MDXfAl7!YkSxSxTj)#m;WZoA%d|LK?Z* z#czKs9JzLjXSY>)v)b47A9bA;#v+e)F-oUitRT(H1z`inu*W=p_1%}B{fr+!+c_C8 zwp$iBhZ)cD>HE)LK3+Q6$WKEwDMMs^Y2|?o#Q6O2`|rLvZ^St<=LF}RbES?Z=Qxhf zzx*`beL5fSU_AZx^ZxqS+V1LtP31Zw2&hxR@g)YrYQdrZ=h0GABMW}TT=7y5eg0|A z{)weH)@qn!n@&h1)ztsmeH^)k+O$AIbti&>ZUdeJh@7LJ+l<+CP+7WlL6nK@5FkI5 zaJ|g-vu~BXH#)SX4uQ)VCu9kh1s<4P)WhJ48y#7=dzojb#PVV-uM&`eCzJi*7rZ>g z+wBG4eX@V`FYu4P=f{s`H?NTJH1a)hf6UvbPv8FfSI3|HllJ(CNgR+zxI2?8!j@re z^B-qa@GEEOp6x2~FU|H(z?g(KT{a$ah&)iMFY-DYMwpFZHh%FpfB(xj-weC+7Au;@ z@BnS$&|2-5w&(7Mw%$R<$@Oly$>q}Lmshhr9w76j9f1)mrDn;pE07lz$%%#%Z}A%Fmbrf#(=Fy^ zR+?BTmfdj5Y$#zyNGs@x33`P!{%F&3F18P7g0OLa_wGOZ?q~npZ~g0EzWder0t_24 zz;1I@1NgYjPt*SNc=uoV@!x?jubdArWbZ2oo&+ZwOuAvRqN7|IVENEFYoyCTWR7qT zsNRy39BhS|E5a|sf`J&v7=zz@^ONts`#qmCNUtW9W2`sGt=BBV=dR8ZPrz;hBDxd++SHF;<-_^2o)eApB6E3{MVE=U0OL3)S87nXSuGR z6K2q7I$AspTwl2Ipfy37E7Ez2s6l@QE)of{AtOq=7d`2gn= zuO8FRVK;kxcl-3)1E9T!&A|u2NdxDcC-9ha&U5hlKg7@f$-!S8Ho-?s29#4q($*4# zoVTXf5nJrU$~g5sH9M=gq@wOdxJ!#9FViWxN`9GswNwkls>HR2e7-b*@HHMOGQhGg zLzaCR;BLN1DnG>(t}SF@4T_993ZFaz)shAIM6=V=U^WD1ivZcvlOwdwV89sp zD`?A@Ycck@P0OINsHQp|-X-P=z7Bn5SRx&FZ94p(ZkP!V#(X_GJr`~2Rn)YICf`fE z06DPxAu(q37mUcHHH%J~0t_qf?ICa2doNRX>HuFrpufZmM^I)_W6CPx)5fjZU=nWn ztYev+-0x#5iz;diF1S<<4V7xl$^fX-u5nZrfG~1m0FQ^g+~$Ab?|S$1AK&ip&hwn} z4l-{>Gm;?B)E`$7xaVBD@q^0=WQAC{VH?(2HInb8wR>s5h5h z3=zVmYv86-3pXwC{mDc&Z`i@F$3IsrN#C+_*Yy0}7yQl^goLm#@ad_W1i;#n@6B(0d12UpVvb?ypecH$@ zg%;cj%-bfGtB|CNP!g34b{qra{PgMdm%kdfJBO`pe%G_6C6$W0#9=cQKCU%pE5u&} zjsZIs+ZPmRxrb&r#xd>ut=~HTqu;*Yzh1Q^1J--y))0*tc$_w0IKKXj`#k3BX*M&> z+9JnPrZoF2|8|!mCEzkkRi%=`Qrv4q5c7qE+WzqbtIq@NSqnwGyWUW$8Y9K@UB)I6 zR-ZNz6L#?BtM`BYr+@t~{Efd#obz@}v%A>}eQnYPzZ`sg`t*e`!hGXvB6hLvx5MML~paYIc+vXHYCf--^=$!HLek*7Gs8g7r~(~;-h z{_p1b40hc3c<|FFe)`pM3wP)k$Qwnd&xf6y=n56jv|0E=MV@Wjo_y^FNRW!uie@7kJ4d;- zwFfh^!@QgHTVXWi%j3Sb!j;5`%>H`lb^&g>^@`^iN+Ik3A|F;R{W1!XtKV9Axs;?b zKAM)^RH=%`tdLq7U)m#F8b*0jWmvu1YO?3cfI{mtKe{U85NFnLUyAUMZKqfNh>;Hy0zw;%n+ zr*D7pu=75Si!726bubn#1v&zDzuBjcUk-lx$Nq=+f8-zI!H;$Nk%%~MRJm=c1=JAE!{_^qj?|7WyL?SDkc|1Io7@L-e0w<%j6ezXJ{gY7MR4Rk1 zTstUDq&)ps;VB`@)4gCc42KbB#~8QUxZN;}oQ75K2?H$`bpi8%nzE){60mqs(MjvF zTGBr-&Ut_EJ|?;LCJ%HqOD{Rjd3(IR;06rKryP*>q`VTht1P=h#FOKFogfoC*^aQ? zt7+Z6W$xdvb|{y1=nOxat9!H`PgX^it7T@Q9x@0F`!e|byXgfT;uzl0oB;TV=YyZW zpReG_S1xbuIwO$K9NM#Sx_EtB)Q7NfE>^$y3%n-cEN$6%+pyfdMWvNx0?s#|S zoLF@S%tz&7$E*kgZUSgF=lh`F?DIGG zzxuD*nm!mfPjb#ljJuf~czylM`}@EBT)T89q)5e}*_mylDU##{RDpbt0y02R^-bO0 zT&)W;&HXtTZyvQp9*a~XNE+8$kiFDqsWYvazJ}3{FY{Y;xM)T!w*Vr>jWEQ4p9UU> z*_Yen_wTPiJ@Aq9oS5WXfNYpJCk!^pIfu<*2W{A#G_Xu;JvhK9h7?CHRr)35XJ~LK zF602w6={LS(-Ld8Y@O#l2r^8pXt^MW@o*?P3Po}S)RXXw<%@SB*-6MRWuBIzTb(|M z!YbbIzuKXAh>mBPQL(_}HrZ|>l0RZM;_6p`kj{}YUljmOU$b5(LLerYFsto0l!$s} zcFi$|Z>eAa>5uHs1zXenjYM#?4^Nd=kxOS=B~aM2E76W#n|qF7?fT_RP_bffd{=GO zqa&BkbGI05`C%+YsX@;_Ke<8CW*=v@rRqwcQo5FS@x&Z8tZ+pz;Y+{{J?aJ?57?bQ z{@Y%My*@y|CXYD*o>6-t_}O@T{QUU*1;fN_tpD?jMbSGACxFk7c{l#XA2!%|&dF)> z9CI4xc^X$mb%KTmPkj71Uq9oxRdgY+L{!^|BV4J&vdGMuxgY6%b3zi zmXyrmU5!l*1L$#4flcIZXh5CSyRVDZI8>( zZE`mSrWjZ)G*e$!1%~V>Sl?g?bugRWwB8E7G$LTT!bh3f$fQNUmN|mXSnuqO5G$2D z;jUa^79o%2~JGd*iCZ!1EKRb}#*Xe9=P zKlSEv)vv9&q@)nc$A`LqdPYe2HizNm!+8JI@$Nm3F=y2FY;e7WgOk8`9frp|vEDsW zqxX$(ttpxCKwygbavVkAbd^J0ulyrxXfuq za)KgD=s*T!4N9cLy`pbLuF=4G9&_Hu?d3Q=jN56PzU*_{SJGh43F5%Qqcy(?x9B2M z_+0qe)uprh*<;n0@6u%GU$C_NQd&>yRjm7o*DpjS$`)b_953(v=rGJ_Yo9h7W;pQ0 z`0>kazy@q$Ma9<|PyCkTqA5jT^JoJ3yzcfm?HxA4kD`P>4+9Q@jRe~ScMK>dvlH8OW5!Sh&Rm+c0F)1^t5 z0ZBnx4yN%t$rq9H^^)Gb_pxjBTryF3*Z7alcO@$eZcK`B)Up^o9 z0Bw@sT2C^|1k6q|+Hs6wfhy(o8=@r%3BYvcNB4u{LHp%*n2$*Ur_FOYPxG{uH~p{! zHjd-aFt^wu!cDMi3?!nev!>mA&?p=LV->~Q{#10e129QZLk8tb#`B{&aJcC5ep*=Ha1;MYqV zBg@&e1fXo~?xrL-sY7?P?x`!UFJrxr*UuC1oqyX$q2`Gr&u+N1NyX!~7PAq*Zh`;@ z=30k5nG@u2w+3(woESLE=A_x2-RNOZ4wf9SEXZedK~7j-az)*6(3`LzFSe1s*77R` zi+p-y7Y^nC*URWUYBmFLjIlh}@I9tDZ~%Y=0pT-?QY$I%|B5(jB4kZ+K0z$R!d0+* zHWOd}8Xz%3V~1STZp=_aL?h#AiSEeP(_t@!<`5-XKW&0XCblhJEw`MbdCWc+s17X0 zaju{sv~AGlIWcUK^IVTo&*}TtET~xv-bT61Sd_4_%yhi!U=^o*D9M7p@nU-TO)81` z7ko`Aqu~aUhYc_k|`+frl-3|=^4&RO;*@MM>F}Xbc41!{m za#mp}7sn4Xj0s@CCdT1~^tHnQ#Eir0;o*q7PW3lgH;CyDqekSk7&#|LtimIXc**z( zM;{Nh5f(w;3eI~t6|xccmucG-Czx@9r}-N%&v+I>!hqceXZ|kF*|ga0?(}y%_%4s0 z2But{y=P)*btGbA)8VUr#7Oi31BHgvAOgl)z@NV(m|5JbUJOctYD=UZRhrdL`3ea9 zB{q4gx?@%Ero1zg61i+kIW+Lxv9^9PTXHU3YN5$kUXDg&7s)t466{G;8EFc*KLKPL z7kf9w17ejwycJi6nT`mEXESZsiasXI>=?&5m&V(|SAB%R)k8|&iza%lroy7i6bI4~ z(A!d7JX+~X!D50+Cn51b2ikeBEDx@R2V6DP<`9<-O{}zNnckF_XK9cZDW4d`unN`O z(`A|0>VY8VHDwOjR)Esys|#^$52WgE&P&|}%C!Y>xUYruloRa+o#OY>7ljq0vi3~r zMx>T96VVpKt@>l~scfc=85#WbwyZY`9ZGO-^}qn?vXs=R%os2NY8Z%lg6HY#TefFa zTqu$obULW^=}p9&*F+iJSFf==gFoeA!gqJ>BJN^eNUn&Kc!UP7XY}9&ucdje-66Qt zJ{GRR*6vCO-ey>#sih5zh_(EU!+2OsW5{#u;}ofc_-8mkc%`*0*n>ufi%-q85;RgP zYu=rV6V5v3tp-fCO-RGhr5OFwyt!Ii`<278rq@0TvLx+XYYo$YnTnSEiO1hesd$sT zUUcMqSrgo0V-!;IzHCkusm;i?B_%P^9C*AlFlV{d@wz)|(SDl+1XvM}@UXK!YY>@_ z7qiLw2OMKZWS~fY%{B7>$|t~R!{Bc;T;1m`^}GL&M0vot^SaEf;Z@$;j6sFr?HXb= zz(ORgYzwd3T}*5b<4LA``TXVe%jYj2KXI*GfQ?~1rwwrMz;R&Q?=QFGmMQ0;H}EL8 zo(Oq0C*od$?se;;$ksr#OSqVSxmcfIZo6D^$WgFqk`oh`o-B2Az2(`4Wi`uUI#>zi zr419D>jh_?p!AtQ)uarQxYG`dL0U!Vv8s780&%PVK7XiaZbLz zzK+NFSZ{ByEs{W=04MYowWUBzp0iyL)u#qnR$x;t0O1_cBx4BKWaci^0`_LA?_?IO zl{}FSlbLMhlg7jsUk)SfYvr0RUbLm+gL%cFB!6a--!d&JZa1wc)v`E)OkcyjTh@G+ zf`r*%yY>{EUpi8L4!=710%`v?lm_cM<-5qM~33bt*Ul-Z5l8O7BI z&NuH9BIbU__9527;3ozkK@C?1B9z^w;%S5?x19UuY8uxAYm{cgK7+YFDz^W|^|$Rp zt2o7n-x}LmOzm3wKbz~?vacHA#(P7<(qoVze~18cf+l{ejYRy8LDj%-55iR?%Kwy) zSgaU?!4BF4a2SuVUN?Yd`CUC!rD{I8uJ48YBv=?8f!f87c?Vr0?RyhPiy(@9VKWw& zhnt!!#-;~8elw#$m%Rw=mDemR@2Qf^Hy2+6ev)VLa6mqOq~Oa82;451eN>1;=X+T7 zQ21R_n4IfslvraDEJMM!h7X;;t9zZJM8wyw6A3Zl)`d>tPF+@PqfU-cH#oVG3M2(j0o9I zH-rF*guo!JcoRBJk(Dy%d&aTrscrG1kDvQTF$OYW^RB+vj+~} zo(yZz6xRZRoYxmiGJESxU13%gk`teKZEKz92yAHf>kM!otLln2-u

|# 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ŷSI3l$)akrbi(v;&C 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Ð7c-hlnZ=3%E%C2r2Vtdn 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

7uUmq0H@!7~l} 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 Hu0mjfMyW#& literal 0 HcmV?d00001 diff --git a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts index eec024b6d4cc11..ce876a303334ac 100644 --- a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts @@ -176,6 +176,9 @@ sourceMapSupport.install({ */ class NullStorageService implements IStorageService { + async readApplicationSharedValue(): Promise { return undefined; } + async compareAndSwapApplicationSharedValue(): Promise<{ swapped: boolean; currentValue: string | undefined }> { return { swapped: false, currentValue: undefined }; } + declare readonly _serviceBrand: undefined; private readonly _onDidChangeValue = new Emitter(); @@ -1105,6 +1108,24 @@ type ThemedFixtures = ReturnType; // Permanent logging layer that detects real timer API usage. // Includes handler source for identification since bundled stack traces are not useful. const realTimeApi = captureGlobalTimeApi(); + +/** Waits for browser-owned readiness without advancing the fixture's virtual clock. */ +export async function waitForFixtureCondition(condition: () => boolean, message: string): Promise { + const requestAnimationFrame = realTimeApi.requestAnimationFrame; + if (!requestAnimationFrame) { + throw new Error('Fixture readiness requires browser animation frames'); + } + let stableFrames = 0; + for (let frame = 0; frame < 120; frame++) { + await new Promise(resolve => requestAnimationFrame(() => resolve())); + stableFrames = condition() ? stableFrames + 1 : 0; + if (stableFrames === 3) { + return; + } + } + throw new Error(message); +} + const logOutsideTime = false; if (logOutsideTime) { const loggingTimeApi = createLoggingTimeApi(realTimeApi, (name, stack, handler) => { diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/accountMenu.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/accountMenu.fixture.ts index b5d2ec35e4c1a3..4f39305a855752 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/accountMenu.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/accountMenu.fixture.ts @@ -9,6 +9,7 @@ import { timeout } from '../../../../../base/common/async.js'; import { IDefaultAccount } from '../../../../../base/common/defaultAccount.js'; import { Event } from '../../../../../base/common/event.js'; import { mock } from '../../../../../base/test/common/mock.js'; +import { URI } from '../../../../../base/common/uri.js'; import { ITextResourceConfigurationService } from '../../../../../editor/common/services/textResourceConfiguration.js'; import { IMenuService } from '../../../../../platform/actions/common/actions.js'; import { MenuService } from '../../../../../platform/actions/common/menuService.js'; @@ -33,7 +34,7 @@ import { ChatEntitlement, IChatEntitlementService } from '../../../../services/c import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { TestTextResourceConfigurationService } from '../../workbenchTestServices.js'; import { configureChatPetFixtureFileRoot, FixtureChatPetService } from '../chat/chatPetFixtureUtils.js'; -import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../fixtureUtils.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices, waitForFixtureCondition } from '../fixtureUtils.js'; type UsageState = 'both' | 'weeklyOnly' | 'fiveHourOnly' | 'unavailable' | 'withoutReset'; type HoveredLimit = 'fiveHour' | 'weekly'; @@ -99,7 +100,12 @@ async function renderAccountMenu(context: ComponentFixtureContext, usageState: U }); registry.definePartialInstance(IAuthenticationService, { onDidChangeSessions: Event.None, - getSessions: async () => [], + getSessions: async () => [{ + id: defaultAccount.sessionId, + accessToken: '', + account: { id: 'fixture-account', label: defaultAccount.accountName, icon: URI.parse(profileImageDataUri) }, + scopes: [], + }], }); registry.definePartialInstance(ICodexAccountService, { agent: 'codex', @@ -150,6 +156,11 @@ async function renderAccountMenu(context: ComponentFixtureContext, usageState: U if (!panel) { throw new Error('Account popover fixture did not open the account panel.'); } + await waitForFixtureCondition(() => { + const images = [...container.querySelectorAll('img')]; + return images.length === 2 && images.every(image => image.hasAttribute('src') && image.complete && image.naturalWidth > 0); + }, 'Account popover fixture did not load both local avatars'); + await Promise.all([...container.querySelectorAll('img')].map(image => image.decode())); if (hoveredLimit) { const rateLimitRows = panel.querySelectorAll('.sessions-account-titlebar-panel-provider-metric-row.secondary[tabindex="0"]'); const row = rateLimitRows[hoveredLimit === 'fiveHour' ? 0 : 1]; @@ -168,31 +179,38 @@ async function renderAccountMenu(context: ComponentFixtureContext, usageState: U export default defineThemedFixtureGroup({ path: 'sessions/' }, { WeeklyAndFiveHourLimits: defineComponentFixture({ + deferPaint: true, labels: { kind: 'screenshot' }, additionalThemes: ['darkHighContrast', 'lightHighContrast'], render: context => renderAccountMenu(context, 'both'), }), WeeklyLimitOnly: defineComponentFixture({ + deferPaint: true, labels: { kind: 'screenshot' }, render: context => renderAccountMenu(context, 'weeklyOnly'), }), FiveHourLimitOnly: defineComponentFixture({ + deferPaint: true, labels: { kind: 'screenshot' }, render: context => renderAccountMenu(context, 'fiveHourOnly'), }), UsageUnavailable: defineComponentFixture({ + deferPaint: true, labels: { kind: 'screenshot' }, render: context => renderAccountMenu(context, 'unavailable'), }), LimitsWithoutReset: defineComponentFixture({ + deferPaint: true, labels: { kind: 'screenshot' }, render: context => renderAccountMenu(context, 'withoutReset'), }), FiveHourLimitHover: defineComponentFixture({ + deferPaint: true, labels: { kind: 'screenshot' }, render: context => renderAccountMenu(context, 'both', 'fiveHour'), }), WeeklyLimitHover: defineComponentFixture({ + deferPaint: true, labels: { kind: 'screenshot' }, render: context => renderAccountMenu(context, 'both', 'weekly'), }), diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts index 8b1d4207bb0051..232af73cd264c6 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts @@ -97,7 +97,7 @@ import { PromptsConfig } from '../../../../contrib/chat/common/promptSyntax/conf import { IAutomationDialogService } from '../../../../contrib/chat/common/automations/automationDialogService.js'; import { IAutomationRunner } from '../../../../contrib/chat/common/automations/automationRunner.js'; import { IAutomationService } from '../../../../contrib/chat/common/automations/automationService.js'; -import { IMcpWorkbenchService, IWorkbenchMcpServer, IMcpService, McpConnectionState, McpServerInstallState, MCP_PLUGIN_COLLECTION_ID_PREFIX } from '../../../../contrib/mcp/common/mcpTypes.js'; +import { IMcpWorkbenchService, IWorkbenchMcpServer, IMcpServer, IMcpService, McpConnectionState, McpServerInstallState, MCP_PLUGIN_COLLECTION_ID_PREFIX } from '../../../../contrib/mcp/common/mcpTypes.js'; import { IMcpRegistry } from '../../../../contrib/mcp/common/mcpRegistryTypes.js'; import { IWorkbenchLocalMcpServer, LocalMcpServerScope } from '../../../../services/mcp/common/mcpWorkbenchManagementService.js'; import { McpListWidget } from '../../../../contrib/chat/browser/aiCustomization/mcpListWidget.js'; @@ -687,6 +687,11 @@ const activeSessionMcpServers: FixtureAgentHostMcpServer[] = [ { id: 'mcp-top-level:fixture:session:Remote Search', name: 'Remote Search', enabled: true, status: McpServerStatus.Error, state: { kind: McpServerStatus.Error, error: { errorType: 'fixture', message: 'Fixture error' } }, logOutputChannelId: 'fixture-agent-host', start: mcpLifecycleNoop, stop: mcpLifecycleNoop, setEnabled() { } }, ]; +const reconciledConnectorMcpServers: FixtureAgentHostMcpServer[] = [ + { ...activeSessionMcpServers[1], id: 'session/github-copilot-connector-93f713b9a912cfbc0384', name: 'github-copilot-connector-93f713b9a912cfbc0384', displayName: 'Microsoft Learn Docs', source: 'managed', sourceUri: undefined }, + { ...activeSessionMcpServers[1], id: 'session/github-copilot-connector-94d26095770df60673dd', name: 'github-copilot-connector-94d26095770df60673dd', displayName: 'Azure Data Explorer', source: 'managed', sourceUri: undefined }, +]; + const allStateMcpServers: FixtureAgentHostMcpServer[] = [ { ...activeSessionMcpServers[0] }, { ...activeSessionMcpServers[0], id: 'mcp-top-level:fixture:session:PostgreSQL', name: 'PostgreSQL', status: McpServerStatus.Error, state: { kind: McpServerStatus.Error, error: { errorType: 'fixture', message: 'Connection refused at localhost:5432. Check that the database is running before starting this server again.' } } }, @@ -927,6 +932,33 @@ const fixtureCopilotConnectors: readonly ICopilotConnector[] = [ }, ]; +const reconciledCopilotConnectors: readonly ICopilotConnector[] = [ + { + name: 'microsoftlearndocsmcpserver', + displayName: 'Microsoft Learn Docs', + description: 'Search official Microsoft documentation.', + tags: ['documentation'], + keywords: ['microsoft', 'learn'], + capabilities: ['Search documentation'], + representativeQueries: [], + connectionStatus: 'connected', + scopes: [], + mcpServers: [{ name: 'microsoftlearndocsmcpserver', type: 'http', url: URI.parse('https://api.github.com/copilot-connectors/api/v1/connectors/microsoftlearndocsmcpserver/mcp') }], + }, + { + name: 'kusto', + displayName: 'Azure Data Explorer', + description: 'Query Azure Data Explorer.', + tags: ['data'], + keywords: ['kusto'], + capabilities: ['Query data'], + representativeQueries: [], + connectionStatus: 'connected', + scopes: [], + mcpServers: [{ name: 'kusto', type: 'http', url: URI.parse('https://api.github.com/copilot-connectors/api/v1/connectors/kusto/mcp') }], + }, +]; + const copilotConnectorMarketplaceResource: ICustomizationMarketplaceResource = { sourceId: 'copilotConnectors', identifier: 'workiq-mail', @@ -1011,6 +1043,8 @@ interface IRenderEditorOptions { readonly height?: number; readonly skillUIIntegrations?: ReadonlyMap; readonly activeSessionMcpServers?: readonly FixtureAgentHostMcpServer[]; + readonly mcpWorkbenchServers?: readonly IWorkbenchMcpServer[]; + readonly mcpRuntimeServers?: readonly IMcpServer[]; readonly mcpServerCompatibility?: readonly ICustomizationMcpServerCompatibility[]; readonly agentHostFiles?: readonly IFixtureFile[]; readonly remoteClientSkillName?: string; @@ -1074,7 +1108,7 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor }, ]; - const allMcpServers = [...mcpWorkspaceServers, ...mcpUserServers]; + const allMcpServers = [...(options.mcpWorkbenchServers ?? [...mcpWorkspaceServers, ...mcpUserServers])]; const toolSets = options.toolSets ?? fixtureToolSets; const selectedPromptType = options.selectedSection === AICustomizationManagementSection.Agents ? PromptsType.agent : options.selectedSection === AICustomizationManagementSection.Skills ? PromptsType.skill @@ -1657,7 +1691,7 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor override async install(server: IWorkbenchMcpServer) { return server; } }()); reg.defineInstance(IMcpService, new class extends mock() { - override readonly servers = constObservable(mcpRuntimeServers as never[]); + override readonly servers = constObservable((options.mcpRuntimeServers ?? mcpRuntimeServers) as never[]); override readonly enablementModel = { readEnabled: (serverId: string) => { if (serverId.includes('mcp-web-search')) { @@ -2643,6 +2677,9 @@ function renderEmbeddedMcpDetail( readonly error?: string; readonly migratable?: boolean; readonly source?: IMcpServerDetailInput['source']; + /** Replaces the server's configuration file with a non-file provenance. */ + readonly provenance?: IMcpServerDetailInput['provenance']; + readonly definitionUnavailable?: IMcpServerDetailInput['definitionUnavailable']; } = {}, ): void { const width = options.width ?? 480; @@ -2699,7 +2736,9 @@ function renderEmbeddedMcpDetail( ...input, error: options.error ? constObservable(options.error) : undefined, migratable: options.migratable, - source: options.source ?? input.source, + source: options.provenance ? undefined : options.source ?? input.source, + provenance: options.provenance, + definitionUnavailable: options.definitionUnavailable, }); } } @@ -2966,6 +3005,23 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { }), }), + McpServersReconciledConnectors: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: false }, + expectedVisualDescriptions: ['The Plugins group contains one Kusto row and one Microsoft Learn Docs row, each with its Connector provenance and Sign In action. No opaque github-copilot-connector rows or Built-In group appear.'], + render: ctx => renderEditor(ctx, { + sessionResource: localSessionResource, + isSessionsWindow: true, + selectedSection: AICustomizationManagementSection.McpServers, + copilotConnectorsEnabled: true, + copilotConnectors: reconciledCopilotConnectors, + marketplaceVisibilityEnabled: true, + activeSessionMcpServers: reconciledConnectorMcpServers, + mcpWorkbenchServers: [], + mcpRuntimeServers: [], + height: 460, + }), + }), + LegacyMcpAvailableWithoutMarketplace: defineComponentFixture({ labels: { kind: 'screenshot', blocksCi: false }, expectedVisualDescriptions: ['With Marketplace visibility off, MCP management keeps the existing gallery in its Available tree group.'], @@ -3868,6 +3924,35 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { })), }), + EmbeddedMcpDetailBuiltinAgent: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: false }, + expectedVisualDescriptions: ['The MCP detail header shows Built-in: Copilot as plain description text in the proportional font below the server name. There is no Edit Configuration button. Configuration explains that Copilot configures this server automatically so its definition can\'t be viewed or edited, followed by an Open Settings link.'], + render: ctx => renderEmbeddedMcpDetail( + ctx, + makeLocalMcpServer('github-mcp-server', 'github-mcp-server', LocalMcpServerScope.User), + { + provenance: { label: 'Built-in: Copilot' }, + definitionUnavailable: { + message: 'Copilot configures this server automatically, so its definition can\'t be viewed or edited.', + settingId: 'chat.agentHost.githubMcpServer.enabled', + }, + }, + ), + }), + + EmbeddedMcpDetailBuiltinExtension: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: false }, + expectedVisualDescriptions: ['The MCP detail header shows Built-in: GitHub Copilot Chat as a themed link in the proportional font below the server name. There is no Edit Configuration button, and the HTTP configuration is shown below.'], + render: ctx => renderEmbeddedMcpDetail( + ctx, + makeLocalMcpServer('github', 'GitHub', LocalMcpServerScope.User, 'GitHub tools from Copilot', { + type: McpServerType.REMOTE, + url: 'https://api.githubcopilot.com/mcp/', + }), + { provenance: { label: 'Built-in: GitHub Copilot Chat', ariaLabel: 'Open extension details for GitHub Copilot Chat', open: () => { } } }, + ), + }), + EmbeddedMcpDetailErrorUnsupported: defineComponentFixture({ labels: { kind: 'screenshot', blocksCi: false }, additionalThemes: ['darkHighContrast', 'lightHighContrast'], diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/chatCompositeBar.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/chatCompositeBar.fixture.ts index 765f609aba70a0..88c15d0d148266 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/chatCompositeBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/chatCompositeBar.fixture.ts @@ -300,6 +300,7 @@ export default defineThemedFixtureGroup({ path: 'sessions/' }, { }), OverflowingTabs: defineComponentFixture({ + deferPaint: true, render: ctx => { const chats = Array.from({ length: 5 }, (_, index) => createMockChat({ title: `Investigate issue ${index + 1}` })); renderBar(ctx, chats, chats[4], { width: 280 }); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/codexContinuationNotice.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/codexContinuationNotice.fixture.ts new file mode 100644 index 00000000000000..1b8916ac5bd517 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/codexContinuationNotice.fixture.ts @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// eslint-disable-next-line local/code-import-patterns +import { SessionsListNotice } from '../../../../../sessions/contrib/sessions/browser/views/sessionsListNotice.js'; +// eslint-disable-next-line local/code-import-patterns +import { renderSessionsListFixture } from '../../../../../sessions/contrib/sessions/test/browser/sessionsListFixtureUtils.js'; +import { CODEX_CONTINUATION_DISABLE_LABEL, CODEX_CONTINUATION_LABEL, CODEX_CONTINUATION_MESSAGE } from '../../../../contrib/chat/browser/agentSessions/agentHost/codexContinuationGuide.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; + +const options = { + description: CODEX_CONTINUATION_MESSAGE, + label: CODEX_CONTINUATION_LABEL, + disableLabel: CODEX_CONTINUATION_DISABLE_LABEL, + dismiss: () => { }, run: () => { }, disable: () => { }, focusSessionsList: () => { }, +}; + +function renderNotice({ container, disposableStore, theme }: ComponentFixtureContext): void { + container.style.width = '360px'; + container.style.backgroundColor = 'var(--vscode-sideBar-background)'; + const instantiation = createEditorServices(disposableStore, { colorTheme: theme }); + container.appendChild(disposableStore.add(instantiation.createInstance(SessionsListNotice, options)).domNode); +} + +async function renderInList(context: ComponentFixtureContext): Promise { + const fixture = await renderSessionsListFixture(context, { + header: {}, view: { width: 400, height: 520 }, + sessions: [ + { id: 'active', title: 'Fix authentication redirect', workspace: 'vscode', minutesAgo: 4 }, + { id: 'tests', title: 'Investigate integration tests', workspace: 'vscode', minutesAgo: 12 }, + ], + }); + const notice = context.disposableStore.add(fixture.instantiationService.createInstance(SessionsListNotice, options)); + fixture.listHost.parentElement!.appendChild(notice.domNode); + fixture.list.layout(fixture.listHost.clientHeight, 400); +} + +export default defineThemedFixtureGroup({ path: 'sessions/' }, { + CodexContinuationNotice: defineComponentFixture({ render: renderNotice, additionalThemes: ['darkHighContrast', 'lightHighContrast'] }), + CodexContinuationNoticeInSessionsView: defineComponentFixture({ render: renderInList, additionalThemes: ['darkHighContrast', 'lightHighContrast'] }), +}); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts index bac8ba85604c32..a68e4e5f8d3f93 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts @@ -10,12 +10,16 @@ import { IObservable, constObservable } from '../../../../../base/common/observa import { mock } from '../../../../../base/test/common/mock.js'; import { URI } from '../../../../../base/common/uri.js'; import { SubmenuItemAction } from '../../../../../platform/actions/common/actions.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; import { AgentSessionApprovalKind, AgentSessionApprovalModel, IAgentSessionApprovalInfo } from '../../../../contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; // eslint-disable-next-line local/code-import-patterns import { IChat, ISession, ISessionWorkspace } from '../../../../../sessions/services/sessions/common/session.js'; // eslint-disable-next-line local/code-import-patterns +import { SESSIONS_CHAT_TABS_SETTING, SessionsChatTabsMode } from '../../../../../sessions/common/sessionConfig.js'; +// eslint-disable-next-line local/code-import-patterns import { IActiveSession, ISessionsManagementService } from '../../../../../sessions/services/sessions/common/sessionsManagement.js'; // eslint-disable-next-line local/code-import-patterns import { ISessionsService } from '../../../../../sessions/services/sessions/browser/sessionsService.js'; @@ -40,18 +44,31 @@ import { ComponentFixtureContext, createEditorServices, defineComponentFixture, // Mock helpers // ============================================================================ -function createMockActiveSession(title: string, workspaceLabel?: string): IActiveSession { +function createMockActiveSession(title: string, workspaceLabel?: string, branch?: string): IActiveSession { let workspace: ISessionWorkspace | undefined; if (workspaceLabel) { const label = workspaceLabel; workspace = new class extends mock() { override readonly label = label; - override readonly folders = []; + override readonly folders = [{ + root: URI.file('/src/vscode'), + workingDirectory: URI.file(branch ? '/src/vscode.worktrees/feature' : '/src/vscode'), + name: label, + description: undefined, + gitRepository: branch ? { + uri: URI.file('/src/vscode'), + workTreeUri: URI.file('/src/vscode.worktrees/feature'), + branchName: branch, + baseBranchName: 'main', + gitHubInfo: constObservable(undefined), + } : undefined, + }]; override readonly isVirtualWorkspace = false; }(); } // A chat without its own folders shares the session's workspace. const activeChat = new class extends mock() { + override readonly title: IObservable = constObservable('Current chat'); override readonly workspace: IObservable = constObservable(workspace); }(); return new class extends mock() { @@ -107,8 +124,9 @@ function buildBlocked(specs: readonly IBlockedSpec[]): { blocked: IBlockedSessio } interface ITitleBarState { - /** The active session shown in the default pill (falls back to "New Session"). */ + /** The active session whose workspace is shown in the default pill. */ activeSession?: IActiveSession; + chatTabsMode?: SessionsChatTabsMode; /** Number of blocked sessions (drives the orange "N sessions require input"). */ blockedCount?: number; /** Explicit typed blocked sessions (drives the specific requires-input message). */ @@ -134,6 +152,9 @@ function renderTitleBar(ctx: ComponentFixtureContext, state: ITitleBarState): vo colorTheme: ctx.theme, additionalServices: (reg) => { registerWorkbenchServices(reg); + reg.defineInstance(IConfigurationService, new TestConfigurationService({ + [SESSIONS_CHAT_TABS_SETTING]: state.chatTabsMode ?? SessionsChatTabsMode.Multiple, + })); reg.defineInstance(ISessionsService, new class extends mock() { override readonly activeSession: IObservable = constObservable(state.activeSession); override readonly visibleSessions: IObservable = constObservable([]); @@ -218,6 +239,36 @@ export default defineThemedFixtureGroup({ path: 'sessions/' }, { }), }), + SessionsTitleBar_SingleChat: defineComponentFixture({ + expectedVisualDescriptions: [ + 'The command center shows a folder icon and "vscode", without a session or chat title.', + ], + render: ctx => renderTitleBar(ctx, { + activeSession: createMockActiveSession('Fix authentication redirect loop', 'vscode'), + chatTabsMode: SessionsChatTabsMode.Single, + }), + }), + + SessionsTitleBar_SingleChatWorktree: defineComponentFixture({ + expectedVisualDescriptions: [ + 'The command center shows a worktree icon, "vscode", and the branch "feature/session-branch", without a session or chat title.', + ], + render: ctx => renderTitleBar(ctx, { + activeSession: createMockActiveSession('Fix authentication redirect loop', 'vscode', 'feature/session-branch'), + chatTabsMode: SessionsChatTabsMode.Single, + }), + }), + + SessionsTitleBar_SingleChatNoWorkspace: defineComponentFixture({ + expectedVisualDescriptions: [ + 'The command center shows a chat icon and "No workspace", without a session or chat title.', + ], + render: ctx => renderTitleBar(ctx, { + activeSession: createMockActiveSession('General help'), + chatTabsMode: SessionsChatTabsMode.Single, + }), + }), + SessionsTitleBar_NoWorkspace: defineComponentFixture({ render: (ctx) => renderTitleBar(ctx, { activeSession: createMockActiveSession('Quick chat'), diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index b35241b11b16df..11876a828e40b8 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -159,24 +159,6 @@ #### sessions/sessionsList/SessionsList_ArchivedNestedChat/Light ![screenshot](https://hediet-screenshots.azurewebsites.net/images/35a21ea76e17222fe3540b27aa2f73712155fd385986747428def909d23513ce) -#### sessions/sessionsList/SessionsList_AutomationsNewBadge_Accent/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/50043fc5cbae5da2ebe57a244cefc2f77d1282e771425edc745694021d966385) - -#### sessions/sessionsList/SessionsList_AutomationsNewBadge_Narrow/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/e6be267da84d1eb280418f66888bdb00c6b8f2dedf0814422acc21cf0a74cdb1) - -#### sessions/sessionsList/SessionsList_AutomationsNewBadge_Running/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/dac619eec2d496d392fc76894cbfe72d6c620e8d0d916fbc092259ce219c16ad) - -#### sessions/sessionsList/SessionsList_AutomationsNewBadge_Soft/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/ca6546aa31546f8a75488b82490bd52cf59ab71fe20bfde1c42d5f687eefed09) - -#### sessions/sessionsList/SessionsList_AutomationsNewBadge_Unread/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/94534fd6bddbf17182ad94cd457d7beda6e90dc2143a7ddae86f97df8b82e60c) - -#### sessions/sessionsList/SessionsList_AutomationsNewBadge/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/6932cbdc45bc378bdeaa0da2840d0bba374c472e5db0fce94f649e6a6b17352a) - #### sessions/sessionsList/SessionsList_CompactArchivedNestedChat/Light ![screenshot](https://hediet-screenshots.azurewebsites.net/images/00755bbb9f478b7fe3720b508d1c0b53897fa6e4511eb3a241284e5dcd0c6d55)