From d7549441304ef8a404515eb9ecbbce92f424a7c4 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 23 Oct 2026 00:14:28 -0500 Subject: [PATCH] =?UTF-8?q?Reconstruct=20batch=20113/124:=202026-10-18=20?= =?UTF-8?q?=E2=86=92=202026-10-20=20(agenthost,=20chat,=20mcp,=20sessions)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashed reconstruction of 50 upstream commits (a5c167e47db..d42fdd97de1). 345 files changed, 21502 insertions(+), 3394 deletions(-) agenthost: - agentHost: route Copilot Connector Sign In through Connector authorization - agentHost: keep Connector Sign In on the authorized account and bounded - agentHost: preserve plugin agent invocation restrictions (#342427) - agentHost: fix session pre-warming losing or orphaning warm launches - agentHost: keep pre-warm skip reasons at trace level - agentHost: route Copilot Connector Sign In through Connector authorization (#342479) - agentHost: refresh discovery after account entitlement changes (#342104) - Preserve hooks in Copilot custom-agent frontmatter (#341712) - agentHost: address review on plugin relocation and restart telemetry (#342676) - agentHost: track Codex edits from turn diffs (#342162) - agentHost: fix session pre-warming losing or orphaning warm launches (#342680) - agentHost: address pre-warming review follow-ups - agentHost: drop deferred-isolation restriction from follow-up - agentHost: retry provisional creation after an in-flight disposal - agentHost: fix Connector authorization test fixture (#342706) - agentHost: address pre-warming review follow-ups (#342712) - agentHost: avoid close notices during relay re-handshakes (#342721) - agentHost: snapshot only the managed settings schema (#342727) - agentHost: move_session server tool to move sessions and nested sessions (#342694) - agentHost: refresh scoped credentials before model and agent selection (#342453) chat: - chat: keep and undo files in the session working set (#342614) - Don't upsell HydraFusion while Fusion presents it (#342652) - [A11y] Signal Codex tool approval requests (#342565) - chat: Prioritize connectors in customization Browse (#342621) - improve migrate with agent skill, use session tool to report results (#340943) - Revert "agentHost: fix Connector authorization test fixture (#342706)" (#342725) - chat: Restore enterprise harness enforcement and explain disabled Local (#342670) - chat: use trophy icon for model comparison toggle (#342688) - chat: preserve file links in remote edit confirmations (#342716) mcp: - mcp: write `stdio` instead of the legacy `local` type for Copilot MCP servers - mcp: write `stdio` instead of the legacy `local` type for Copilot MCP servers (#342642) - mcp: offer all user locations in Open User Configuration - mcp: only offer the legacy user mcp.json when it exists - mcp: offer all user locations in Open User Configuration (#342720) sessions: - sessions: move chats and sessions by drag and drop in the sessions list (#342624) - sessions: improve model comparison discovery and folderless chats (#342656) - sessions: create pre-warmable folder drafts only when sessions pre-warm - sessions: fix comparison picker state and shortcut discovery (#342682) - sessions: extend sessions list drag and drop across placements (#342591) editor: - editor: paste the paths of files copied in a file manager (#342695) - editor: paste the paths of files copied in a file manager (#342695) canvases: - Canvases: Gate project canvas extensions on workspace trust (#342372) sanity: - sanity: check webview resource loading (#342227) terminal: - terminal: Keep extension PATH contributions on their originating host (#341925) extensions/copilot: - Bump version to 1.144.0 (#342485) dependencies: - Revert "Update Foundry Local SDK to 2.1.0" (#342681) engineering: - build(deps): bump actions/cache/save from 5.1.0 to 6.1.0 (#334237) github: - github: migrate slow-extension issue search to shared clients (#342625) agent host: - agent host: don't fail session restore on blob attachments that aren't valid base64 (#342662) fix: - fix: update sanity pipeline images for setuid tests (#342675) Co-Authored-By: Claude Opus 5.5 --- .../github-managed-settings.md | 2 +- .../policy-support-maintenance.md | 4 +- .../sdk-runtime-policy.md | 10 +- .../vscode-policy.md | 4 +- .github/workflows/chat-perf.yml | 4 +- .../product-build-template.yml | 20 +- build/azure-pipelines/product-build.yml | 20 +- .../azure-pipelines/product-sanity-tests.yml | 20 +- build/lib/policies/policyData.jsonc | 2 +- build/lib/test/dictationRuntime.test.ts | 10 +- copilot_shim/REQUIREMENTS.md | 19 + .../skills/migrate-customizations/SKILL.md | 35 +- extensions/copilot/package-lock.json | 6 +- extensions/copilot/package.json | 4 +- .../test/node/builtinSkills.spec.ts | 14 +- package-lock.json | 20 +- package.json | 8 +- remote/web/package-lock.json | 8 +- remote/web/package.json | 2 +- .../chat-simulation/common/mock-llm-server.ts | 67 +- src/vs/base/common/codiconsLibrary.ts | 1 + .../controller/editContext/clipboardUtils.ts | 13 +- .../browser/controller/clipboardUtils.test.ts | 43 + .../browser/tabbedActionListWidget.ts | 14 + src/vs/platform/agentHost/AGENTS.md | 7 +- src/vs/platform/agentHost/PERFORMANCE.md | 15 + .../browser/agentHostProtocolClient.ts | 16 + .../agentHost/browser/nullAgentHostService.ts | 2 + src/vs/platform/agentHost/common/agent.ts | 11 +- .../agentHost/common/agentHostChatMove.ts | 4 +- .../common/agentHostEnablementService.ts | 7 +- .../agentHost/common/agentHostSchema.ts | 9 +- .../common/agentHostSessionStateStore.ts | 4 +- .../agentHostStarter.config.contribution.ts | 7 + .../agentHost/common/agentServerTools.ts | 15 +- .../platform/agentHost/common/agentService.ts | 6 + .../common/meta/agentEntitlementHintMeta.ts | 7 + .../common/meta/mcpCustomizationMeta.ts | 2 +- .../meta/vscode/agentEntitlementHintMeta.ts | 31 + .../meta/vscode/mcpCustomizationMeta.ts | 18 + .../agentHost/common/serverToolNames.ts | 1 + .../agentHost/common/sessionDataService.ts | 13 +- .../electron-browser/localAgentHostService.ts | 5 + .../node/agentHostAuthenticationService.ts | 2 +- .../node/agentHostChangesetService.ts | 36 +- .../agentHost/node/agentHostChatRowStore.ts | 16 +- .../agentHost/node/agentHostLocalTurns.ts | 8 +- .../node/agentHostSessionPrewarmer.ts | 11 +- .../platform/agentHost/node/agentService.ts | 328 ++- .../agentHost/node/agentServiceComposition.ts | 18 + .../agentHost/node/agentServiceFoundation.ts | 5 + .../agentHost/node/agentSideEffects.ts | 12 +- .../builtInChatContributions.ts | 2 + .../checkpointAndChangesetContribution.ts | 5 + .../scheduledChatMovesContribution.ts | 34 + .../scheduledChatMovesService.ts | 87 + .../agentHost/node/claude/claudeCanUseTool.ts | 2 +- .../agentHost/node/codex/codexAgent.ts | 201 +- .../agentHost/node/codex/codexLaunchConfig.ts | 3 +- .../agentHost/node/codex/codexTurnDiff.ts | 300 +++ .../node/codex/codexTurnDiffTracker.ts | 253 ++ .../node/compatibleSessionDatabase.ts | 4 + .../agentHost/node/copilot/copilotAgent.ts | 274 +- .../node/copilot/copilotAgentSession.ts | 236 +- .../node/copilot/copilotAttachmentUtils.ts | 100 + .../node/copilot/copilotConnectors.ts | 32 +- .../node/copilot/copilotPluginConverters.ts | 104 +- .../copilotProjectExtensionLaunchPolicy.ts | 251 ++ .../copilot/copilotRuntimeRestartTelemetry.ts | 2 +- .../node/copilot/copilotSessionLauncher.ts | 11 +- .../copilotCustomizationInstallations.ts | 1 + .../node/copilot/mapSessionEvents.ts | 6 +- .../node/missionControl/MISSION_CONTROL.md | 4 + .../missionControlProtocolServer.ts | 16 +- .../agentHost/node/sessionDatabase.ts | 23 +- .../agentHost/node/sessionDiffAggregator.ts | 11 + .../node/shared/agentServerToolHost.ts | 14 +- .../node/shared/copilotApiService.ts | 139 +- .../node/shared/mcpCustomizationController.ts | 20 +- .../agentHost/node/shared/serverToolGroups.ts | 11 +- .../node/shared/sessionMcpDiscovery.ts | 5 +- .../agentHost/node/shared/sessionMoveTools.ts | 440 ++++ .../node/shared/sessionServerTools.ts | 109 +- .../common/agentEntitlementHintMeta.test.ts | 22 + .../test/common/sessionTestHelpers.ts | 15 + .../agentHostProtocolClient.test.ts | 64 +- ...gs_schema_matches_the_snapshot.schema.json | 2321 ++++++++--------- .../node/agentHostChangesetService.test.ts | 71 +- .../node/agentHostChangesetStrategy.test.ts | 2 + .../test/node/agentHostLocalTurns.test.ts | 18 +- ...gentHostSessionStateOwnershipGuard.test.ts | 4 +- .../node/agentHostToolCallTelemetry.test.ts | 2 + .../node/agentHostTurnHangTelemetry.test.ts | 2 + .../test/node/agentHostTurnTelemetry.test.ts | 2 + .../agentHost/test/node/agentService.test.ts | 430 ++- .../test/node/agentSideEffects.test.ts | 17 +- .../test/node/chatContributions.test.ts | 20 +- .../agentHost/test/node/claudeAgent.test.ts | 47 +- .../node/claudeServerToolMcpServer.test.ts | 1 + .../test/node/codex/codexCreateChat.test.ts | 12 +- .../test/node/codex/codexLaunchConfig.test.ts | 1 + .../node/codex/codexPrewarmEviction.test.ts | 1 + .../test/node/codex/codexSkuTelemetry.test.ts | 3 +- .../test/node/codex/codexTurnDiff.test.ts | 373 +++ .../agentHost/test/node/copilotAgent.test.ts | 532 +++- .../test/node/copilotAgentSession.test.ts | 243 +- .../copilotCustomizationInstallations.test.ts | 9 +- .../test/node/copilotPluginConverters.test.ts | 182 +- ...opilotProjectExtensionLaunchPolicy.test.ts | 232 ++ ...pilotSdkManagedSettings.integrationTest.ts | 2 +- .../test/node/copilotSessionLauncher.test.ts | 4 +- .../agentHost/test/node/e2e/KNOWN_ISSUES.md | 4 + ...-move-session-explains-a-refused-move.yaml | 48 + ...akes-a-nested-session-its-own-session.yaml | 48 + ...a-nested-session-into-another-session.yaml | 57 + ...-a-whole-session-into-another-session.yaml | 48 + ...-calling-chat-only-once-its-turn-ends.yaml | 46 + ...s-a-nested-session-within-its-session.yaml | 59 + ...-move-session-explains-a-refused-move.yaml | 52 + ...akes-a-nested-session-its-own-session.yaml | 48 + ...a-nested-session-into-another-session.yaml | 57 + ...-a-whole-session-into-another-session.yaml | 52 + ...-calling-chat-only-once-its-turn-ends.yaml | 50 + ...s-a-nested-session-within-its-session.yaml | 63 + ...-move-session-explains-a-refused-move.yaml | 49 + ...akes-a-nested-session-its-own-session.yaml | 49 + ...a-nested-session-into-another-session.yaml | 58 + ...-a-whole-session-into-another-session.yaml | 48 + ...-calling-chat-only-once-its-turn-ends.yaml | 47 + ...s-a-nested-session-within-its-session.yaml | 59 + ...Copilot_prompts_claude-haiku-4_5.prompt.md | 4 +- ..._Copilot_prompts_claude-opus-4_5.prompt.md | 4 +- ..._Copilot_prompts_claude-opus-4_6.prompt.md | 4 +- ..._Copilot_prompts_claude-opus-4_7.prompt.md | 4 +- ...___Copilot_prompts_claude-opus-5.prompt.md | 4 +- ..._Copilot_prompts_claude-opus-5_5.prompt.md | 4 +- ...opilot_prompts_claude-sonnet-4_5.prompt.md | 4 +- ...opilot_prompts_claude-sonnet-4_6.prompt.md | 4 +- ..._Copilot_prompts_claude-sonnet-5.prompt.md | 4 +- ...Copilot_prompts_gemini-2_0-flash.prompt.md | 4 +- ...2E___Copilot_prompts_gpt-5-codex.prompt.md | 4 +- ...E2E___Copilot_prompts_gpt-5-mini.prompt.md | 4 +- ...Host_E2E___Copilot_prompts_gpt-5.prompt.md | 4 +- ...pilot_prompts_gpt-5_1-codex-mini.prompt.md | 4 +- ...___Copilot_prompts_gpt-5_1-codex.prompt.md | 4 +- ...st_E2E___Copilot_prompts_gpt-5_1.prompt.md | 4 +- ...E___Copilot_prompts_gpt-5_6-luna.prompt.md | 4 +- ...2E___Copilot_prompts_gpt-5_6-sol.prompt.md | 4 +- ...___Copilot_prompts_gpt-5_6-terra.prompt.md | 4 +- .../test/node/e2e/suites/serverToolsSuite.ts | 197 +- .../test/node/mapSessionEvents.test.ts | 137 + .../node/missionControlProtocolServer.test.ts | 86 +- .../test/node/protocolServerHandler.test.ts | 7 +- .../codexTurnDiff.integrationTest.ts | 518 ++++ .../copilotConnectors.integrationTest.ts | 2 +- .../copilotMockLlm.integrationTest.ts | 152 +- .../test/node/scheduledChatMoves.test.ts | 118 + .../test/node/serverToolGroups.test.ts | 12 + .../test/node/sessionDatabase.test.ts | 35 + .../test/node/sessionDiffAggregator.test.ts | 26 + .../test/node/sessionServerTools.test.ts | 467 +++- .../copilotApiService.integrationTest.ts | 3 +- .../node/shared/copilotApiService.test.ts | 221 +- .../node/shared/sessionMcpDiscovery.test.ts | 15 + .../common/customizationMarketplaceService.ts | 1 + src/vs/platform/github/README.md | 6 +- src/vs/platform/github/client-inventory.md | 1 + .../github/common/remote/ipcClient.ts | 5 +- .../github/test/common/githubChannel.test.ts | 44 +- .../mcp/common/mcpCopilotConfiguration.ts | 5 +- .../mcp/common/mcpResourceScannerService.ts | 7 +- .../common/mcpCopilotConfiguration.test.ts | 6 +- .../common/mcpResourceScannerService.test.ts | 4 +- .../policy/common/copilotManagedSettings.ts | 33 +- .../common/copilotManagedSettings.test.ts | 70 +- .../workspace/common/workspaceTrust.ts | 2 + src/vs/sessions/AI_CUSTOMIZATIONS.md | 2 +- src/vs/sessions/SESSIONS_LIST.md | 18 +- src/vs/sessions/SESSION_COMPARISONS.md | 6 +- src/vs/sessions/browser/parts/chatView.ts | 2 + src/vs/sessions/browser/parts/sessionView.ts | 4 + .../common/agentHostSessionsProvider.ts | 17 +- src/vs/sessions/common/sessionCommands.ts | 4 + .../contrib/chat/browser/chat.contribution.ts | 10 +- .../sessions/contrib/chat/browser/chatView.ts | 6 + .../contrib/chat/browser/modelPicker.ts | 36 +- .../contrib/chat/browser/newChatInput.ts | 13 +- .../chat/browser/newChatModelPicker.ts | 1 + .../contrib/chat/browser/newChatWidget.ts | 35 +- .../chat/browser/newSessionComposerService.ts | 1 + .../sessionComparisonModelSelection.ts | 14 +- .../chat/browser/sessionModelSelection.ts | 10 +- .../browser/sessionsChatAccessibilityHelp.ts | 3 + .../sessions/contrib/chat/common/constants.ts | 1 + .../chat/test/browser/modelPicker.test.ts | 65 +- .../chat/test/browser/newChatWidget.test.ts | 73 +- .../sessionComparisonModelPicker.fixture.ts | 12 +- .../sessionComparisonModelSelection.test.ts | 20 + .../browser/sessionModelSelection.test.ts | 121 +- .../browser/sessionWorkspacePicker.test.ts | 4 + .../sessionsChatAccessibilityHelp.test.ts | 4 + .../agentHost/AGENT_HOST_SESSIONS_PROVIDER.md | 4 + .../browser/agentHostChatMoveErrors.ts | 98 + .../browser/baseAgentHostSessionsProvider.ts | 122 +- .../browser/agentHostChatMoveErrors.test.ts | 43 + .../localAgentHostSessionsProvider.test.ts | 118 +- .../browser/copilotChatSessionsProvider.ts | 20 +- .../remoteAgentHost.contribution.test.ts | 4 + .../sessions/browser/media/sessionsList.css | 20 + .../sessions/browser/sessionsActions.ts | 45 +- .../sessions/browser/views/sessionsList.ts | 1290 ++++++--- .../browser/views/sessionsListChatMove.ts | 311 +++ .../browser/views/sessionsViewActions.ts | 55 +- .../browser/sessionsArchiveActions.test.ts | 2 +- .../test/browser/sessionsList.test.ts | 634 ++++- .../browser/sessionsListChatMoveDnd.test.ts | 965 +++++++ .../browser/sessionsListContextMenu.test.ts | 122 +- .../test/browser/sessionsListFixtureUtils.ts | 13 + .../test/browser/sessionsListTestUtils.ts | 90 +- .../browser/sessionComparisonService.ts | 33 +- .../browser/sessionsListModelService.ts | 88 +- .../browser/sessionsManagementService.ts | 30 +- .../sessions/browser/sessionsService.ts | 28 +- .../sessions/common/sessionComparison.ts | 4 +- .../sessions/common/sessionsManagement.ts | 23 + .../sessions/common/sessionsProvider.ts | 49 +- .../browser/sessionComparisonService.test.ts | 47 +- .../test/browser/sessionNavigation.test.ts | 4 + .../browser/sessionsManagementService.test.ts | 104 +- .../api/browser/mainThreadTerminalService.ts | 11 +- .../browser/mainThreadTerminalService.test.ts | 144 +- .../browser/actions/chatAccessibilityHelp.ts | 13 +- .../agentHost/agentHostActiveClientService.ts | 89 +- .../agentSessions/agentHost/agentHostAuth.ts | 97 +- ...CustomizationMarketplaceInstallProvider.ts | 1 + .../agentHostCustomizationService.ts | 219 +- .../agentHost/agentHostPolicyContinuation.ts | 2 +- .../agentHost/agentHostResponseFileChanges.ts | 24 +- .../agentHost/agentHostSessionHandler.ts | 77 +- .../agentHost/agentHostSessionInputPills.ts | 160 +- .../agentHost/agentHostSnapshotController.ts | 58 +- ...ntHostUntitledProvisionalSessionService.ts | 81 +- .../agentHost/stateToProgressAdapter.ts | 19 +- .../agentSessions/sessionTypeAvailability.ts | 13 + .../aiCustomizationDiscoveryPage.ts | 357 ++- .../aiCustomizationManagement.contribution.ts | 6 + .../aiCustomizationManagementEditor.ts | 189 +- .../copilotConnectorsService.ts | 32 +- .../aiCustomization/customizationMigration.ts | 15 +- .../customizationMigrationAccessibility.ts | 4 +- .../customizationMigrationDashboard.ts | 50 +- .../customizationMigrationRecovery.ts | 357 +++ .../customizationMigrationResultTool.ts | 123 + .../mcpServerCustomizationMigration.ts | 2 + .../media/aiCustomizationDiscovery.css | 137 + .../media/customizationMigrationDashboard.css | 50 + .../chat/browser/chat.shared.contribution.ts | 2 +- .../chatSessions/chatSessions.contribution.ts | 16 +- .../chatChangesSummaryPart.ts | 141 +- .../chatModifiedFilesConfirmationSubPart.ts | 6 +- .../contrib/chat/browser/widget/chatWidget.ts | 5 +- .../browser/widget/input/chatInputPart.ts | 53 +- .../widget/input/chatSessionWorkingSet.ts | 222 +- .../input/modelPicker/media/modelPicker.css | 19 +- .../modelPicker/modelPickerActionItem.ts | 23 +- .../modelPicker/modelPickerComparison.ts | 12 +- .../modelPicker/modelPickerPresentation.ts | 4 +- .../modelPicker/modelPickerTabbedWidget.ts | 63 +- .../input/modelPicker/modelPickerWidget.ts | 33 +- .../input/sessionTargetPickerActionItem.ts | 39 +- .../chat/browser/widget/media/chat.css | 25 + .../widgetHosts/editor/chatEditorInput.ts | 12 +- .../widgetHosts/viewPane/chatViewPane.ts | 10 +- .../common/chatService/chatServiceImpl.ts | 5 +- .../contrib/chat/common/constants.ts | 74 +- .../customizationMigrationTelemetryService.ts | 256 +- .../chatAccessibilityHelp.test.ts | 43 +- .../agentHostSessionInputPills.test.ts | 387 ++- .../agentHostSnapshotController.test.ts | 67 +- ...mizationMarketplaceInstallProvider.test.ts | 4 + .../agentSessions/agentHostAuth.test.ts | 165 +- .../agentHostChatContribution.test.ts | 24 +- .../agentHostClientTools.test.ts | 64 + .../agentHostCustomizationService.test.ts | 178 +- .../agentHostPolicyContinuation.test.ts | 6 +- ...tUntitledProvisionalSessionService.test.ts | 130 +- .../sessionTypeAvailability.test.ts | 6 + .../stateToProgressAdapter.test.ts | 20 + .../aiCustomizationDiscoveryPage.test.ts | 295 ++- .../aiCustomizationManagementEditor.test.ts | 170 +- ...pilotConnectorsMarketplaceProvider.test.ts | 11 + .../copilotConnectorsService.test.ts | 16 + .../customizationMigration.test.ts | 10 +- .../customizationMigrationDashboard.test.ts | 36 +- .../customizationMigrationRecovery.test.ts | 155 ++ .../customizationMigrationResultTool.test.ts | 124 + .../customizationMigrationServiceImpl.test.ts | 32 +- .../mcpServerCustomizationMigration.test.ts | 60 +- .../chatChangesSummaryPart.test.ts | 109 + ...atModifiedFilesConfirmationSubPart.test.ts | 97 +- .../test/browser/widget/chatWidget.test.ts | 45 +- .../widget/input/chatInputPart.test.ts | 78 +- .../input/chatSessionWorkingSet.test.ts | 244 +- .../modelPicker/modelPickerActionItem.test.ts | 83 +- .../modelPickerTabbedWidget.test.ts | 330 ++- .../sessionTargetPickerActionItem.test.ts | 233 +- .../editor/chatEditorInput.test.ts | 22 +- .../widgetHosts/viewPane/chatViewPane.test.ts | 24 +- .../common/chatService/chatService.test.ts | 61 +- .../chat/test/common/constants.test.ts | 228 +- ...omizationMigrationTelemetryService.test.ts | 191 +- .../electron-browser/extensionsSlowActions.ts | 43 +- .../extensionsSlowActions.test.ts | 213 ++ .../contrib/mcp/browser/mcpCommands.ts | 84 +- .../contrib/mcp/browser/mcpMigration.ts | 8 +- .../mcpConfigurationEntryPoints.test.ts | 79 +- .../terminal/browser/remoteTerminalBackend.ts | 3 +- .../terminal/browser/terminalInstance.ts | 6 +- .../browser/terminalProcessManager.ts | 42 +- .../terminal/common/environmentVariable.ts | 12 + .../common/environmentVariableService.ts | 55 +- .../common/remote/remoteTerminalChannel.ts | 9 +- .../contrib/terminal/common/terminal.ts | 2 +- .../electron-browser/localTerminalBackend.ts | 23 +- .../test/browser/agentHostPty.test.ts | 1 + .../browser/terminalProcessManager.test.ts | 149 +- .../common/environmentVariableService.test.ts | 150 +- .../remote/remoteTerminalChannel.test.ts | 150 ++ .../localTerminalBackend.test.ts | 127 +- .../editorRemoteAgentHostServiceClient.ts | 5 + .../extensions/browser/extensionService.ts | 1 + .../extensions/common/extHostCustomers.ts | 2 + .../extensions/common/extensionHostManager.ts | 1 + .../services/extensions/common/extensions.ts | 3 + .../extensions/common/remoteExtensionHost.ts | 3 + .../nativeExtensionService.ts | 1 + .../test/browser/accountPolicyService.test.ts | 42 +- .../workspaces/common/workspaceTrust.ts | 4 + .../test/common/workspaceTrust.test.ts | 8 + .../chat/sessionTargetPicker.fixture.ts | 58 + ...aiCustomizationManagementEditor.fixture.ts | 18 +- ...customizationMigrationDashboard.fixture.ts | 2 + test/sanity/README.md | 37 +- test/sanity/containers/entrypoint.sh | 17 +- test/sanity/src/uiTest.ts | 96 +- 345 files changed, 21502 insertions(+), 3394 deletions(-) create mode 100644 src/vs/editor/test/browser/controller/clipboardUtils.test.ts create mode 100644 src/vs/platform/agentHost/common/meta/agentEntitlementHintMeta.ts create mode 100644 src/vs/platform/agentHost/common/meta/vscode/agentEntitlementHintMeta.ts create mode 100644 src/vs/platform/agentHost/node/chatContributions/scheduledChatMoves/scheduledChatMovesContribution.ts create mode 100644 src/vs/platform/agentHost/node/chatContributions/scheduledChatMoves/scheduledChatMovesService.ts create mode 100644 src/vs/platform/agentHost/node/codex/codexTurnDiff.ts create mode 100644 src/vs/platform/agentHost/node/codex/codexTurnDiffTracker.ts create mode 100644 src/vs/platform/agentHost/node/copilot/copilotProjectExtensionLaunchPolicy.ts create mode 100644 src/vs/platform/agentHost/node/shared/sessionMoveTools.ts create mode 100644 src/vs/platform/agentHost/test/common/agentEntitlementHintMeta.test.ts create mode 100644 src/vs/platform/agentHost/test/node/codex/codexTurnDiff.test.ts create mode 100644 src/vs/platform/agentHost/test/node/copilotProjectExtensionLaunchPolicy.test.ts create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-move-session-explains-a-refused-move.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-move-session-makes-a-nested-session-its-own-session.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-move-session-moves-a-nested-session-into-another-session.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-move-session-moves-a-whole-session-into-another-session.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-move-session-moves-the-calling-chat-only-once-its-turn-ends.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-move-session-reorders-a-nested-session-within-its-session.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-move-session-explains-a-refused-move.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-move-session-makes-a-nested-session-its-own-session.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-move-session-moves-a-nested-session-into-another-session.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-move-session-moves-a-whole-session-into-another-session.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-move-session-moves-the-calling-chat-only-once-its-turn-ends.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-move-session-reorders-a-nested-session-within-its-session.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-move-session-explains-a-refused-move.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-move-session-makes-a-nested-session-its-own-session.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-move-session-moves-a-nested-session-into-another-session.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-move-session-moves-a-whole-session-into-another-session.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-move-session-moves-the-calling-chat-only-once-its-turn-ends.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-move-session-reorders-a-nested-session-within-its-session.yaml create mode 100644 src/vs/platform/agentHost/test/node/providerIntegration/codexTurnDiff.integrationTest.ts create mode 100644 src/vs/platform/agentHost/test/node/scheduledChatMoves.test.ts create mode 100644 src/vs/sessions/contrib/providers/agentHost/browser/agentHostChatMoveErrors.ts create mode 100644 src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostChatMoveErrors.test.ts create mode 100644 src/vs/sessions/contrib/sessions/browser/views/sessionsListChatMove.ts create mode 100644 src/vs/sessions/contrib/sessions/test/browser/sessionsListChatMoveDnd.test.ts create mode 100644 src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationRecovery.ts create mode 100644 src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMigrationResultTool.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigrationRecovery.test.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationMigrationResultTool.test.ts create mode 100644 src/vs/workbench/contrib/extensions/test/electron-browser/extensionsSlowActions.test.ts create mode 100644 src/vs/workbench/contrib/terminal/test/common/remote/remoteTerminalChannel.test.ts create mode 100644 src/vs/workbench/test/browser/componentFixtures/chat/sessionTargetPicker.fixture.ts diff --git a/.github/skills/policy-and-managed-settings/github-managed-settings.md b/.github/skills/policy-and-managed-settings/github-managed-settings.md index a756c7f00a174d..cd35c3508edb58 100644 --- a/.github/skills/policy-and-managed-settings/github-managed-settings.md +++ b/.github/skills/policy-and-managed-settings/github-managed-settings.md @@ -152,7 +152,7 @@ managedSettings: { [COPILOT_ENABLED_PLUGINS_KEY]: { type: 'string' } }, `IPolicyData.managedSettingsActive` is `true` when a channel contributes managed-settings governance. `AccountPolicyService.getPolicyData` derives it from effective managed settings and raw settings that normalization cannot represent, independently of which keys VS Code declares. Presence governs for existing settings. The `vscode.temporaryAllowLocalHarness` control is excluded from this flag at every value; its explicit false value separately disables Local, Claude, and Codex without affecting unrelated presence-gated features. Channel resolution retains its value for precedence and diagnostics, but neither its normalized value nor its raw container contributes general governance. Empty server responses or responses containing only this control explicitly clear a previously cached raw-governance flag. -The `Claude3PIntegration` and `Codex3PIntegration` policies both use `thirdPartyAgentEnabledValue`, which forces its setting to `false` when the account disables chat preview features, when `managedSettingsActive` is `true`, or when effective `vscode.temporaryAllowLocalHarness` is boolean `false`. Rationale: managed settings are composed and enforced by the Copilot runtime and never reach the Claude or Codex harnesses, so leaving those harnesses available would hand a governed user an ungoverned path around every control the enterprise set. Local eligibility is separate: managed `permissions.*` and `sandbox.*` do not change its harness-selection logic, while `vscode.temporaryAllowLocalHarness: false` explicitly prohibits Local. +The `Claude3PIntegration` and `Codex3PIntegration` policies both use `thirdPartyAgentEnabledValue`, which forces its setting to `false` when the account disables chat preview features, when `managedSettingsActive` is `true`, or when effective `vscode.temporaryAllowLocalHarness` is boolean `false`. Rationale: managed settings are composed and enforced by the Copilot runtime and never reach the Claude or Codex harnesses, so leaving those harnesses available would hand a governed user an ungoverned path around every control the enterprise set. This mirrors the runtime-owned `sandbox.enabled` floor retiring the local harness (`IAgentHostEnablementService.managedSandboxEnforced`). Invariants: diff --git a/.github/skills/policy-and-managed-settings/policy-support-maintenance.md b/.github/skills/policy-and-managed-settings/policy-support-maintenance.md index a3849f3e2e2f92..c0f37b528c50bf 100644 --- a/.github/skills/policy-and-managed-settings/policy-support-maintenance.md +++ b/.github/skills/policy-and-managed-settings/policy-support-maintenance.md @@ -44,7 +44,7 @@ Preserve these boundaries: - Readiness is report-only. Applied gaps never change rollout, harness selection, or Local picker visibility. There is no compatibility holdback or acceptance setting/policy. Accepting a gap for rollout must not relabel it as enforced. - Existing experiments, preferences, remembered/inherited and explicit choices retain their behavior. No storage mutation, conversation migration, Chat banners, message blocking, or additional telemetry. - `ChatEditorPreferCopilotHarness` is a preference, not a hard Local/Copilot lock. False does not prohibit explicit or remembered Copilot choices. Hard mandates require an explicit enforcement contract outside this diagnostic feature. -- Existing enterprise-required sandbox enforcement in Copilot and managed-settings freshness behavior remains authoritative. Local cannot satisfy the runtime sandbox floor, but `permissions.*` and `sandbox.*` do not affect harness selection or Local eligibility. +- Existing enterprise-required sandbox and managed-settings freshness behavior remains authoritative. Local cannot satisfy the runtime sandbox floor. - A readiness report with no known gaps is not an attestation of runtime parity. State what the detector does not inspect. Update the inventory, predicates, descriptions, readiness report, and tests as one coherent change. Keep UI descriptions focused on concrete admin/user consequences rather than copying a raw status label. @@ -59,7 +59,7 @@ The readiness summary reports policy names, configuration keys, source labels, a - Test absent, permissive, restrictive, partial, and removed policy values. - Test Local and Copilot rollout defaults with and without applied gaps. Diagnostics must neither veto Agent Host selection nor promote a Local baseline. -- Cover remembered and inherited automatic choices without storage mutation, explicit choices, other providers, virtual workspaces, host unavailability, hidden-Local picker behavior, and independence from runtime sandbox settings. +- Cover remembered and inherited automatic choices without storage mutation, explicit choices, other providers, virtual workspaces, host unavailability, hidden-Local picker behavior, and sandbox precedence. - Test existing harness policy-over-user precedence using real configuration layering, and test policy removal and inventory graduation to enforced. - Test readiness content, including uncertainty and safe rendering. Do not include raw sensitive values in new diagnostics summaries. - For bridge changes, exercise the bundled runtime with isolated temporary homes and a synthetic local model. Check create, cold resume, removal, managed asks under Allow All/assisted modes, and unaffected requests. No real Copilot sign-in or device-policy changes should be needed for these fixtures. diff --git a/.github/skills/policy-and-managed-settings/sdk-runtime-policy.md b/.github/skills/policy-and-managed-settings/sdk-runtime-policy.md index f0b3dd0f1d5520..8251187bd38a77 100644 --- a/.github/skills/policy-and-managed-settings/sdk-runtime-policy.md +++ b/.github/skills/policy-and-managed-settings/sdk-runtime-policy.md @@ -34,21 +34,23 @@ Do not add a VS Code `policy:` merely to mirror runtime policy. Do not add a Git ### Harness eligibility for managed policy -Managed `permissions.*` and `sandbox.*` settings do not select a harness, hide Local, block Local requests, or migrate Local history. Harness selection follows the existing rollout settings, preferences, remembered and explicit choices, and availability logic. Only `vscode.temporaryAllowLocalHarness: false` explicitly prohibits Local. VS Code preserves permission-key presence across server and file delivery and watches the runtime's declared permission keys in native MDM; it does not parse or enforce rule grammar. Local does not enforce runtime-only sandbox and permission settings. Permissions do not turn sandboxing on. +A non-empty effective managed `permissions.deny` list, or managed `sandbox.enabled: true`, requires Copilot Agent Host, subject to the existing temporary Local escape hatch. A valid empty deny array, other `permissions.*` settings, and other sandbox settings do not independently require Agent Host. Malformed deny values remain fail-closed for runtime validation. VS Code only checks whether the deny list is empty; it does not interpret or enforce rule grammar. Native MDM continues watching the runtime's declared permission keys. Permissions do not turn sandboxing on. Explicit `vscode.temporaryAllowLocalHarness: false` still overrides the escape hatch. Claude/Codex retain their separate presence-based managed-settings governance. + +When effective enterprise policy prohibits Local, the harness picker keeps its row visible but disabled, with a policy explanation and a link to learn why Local is disabled. This presentation does not make Local selectable, usable as a fallback, or exempt from request blocking and migration. Personal visibility settings still apply when no enterprise restriction is active. The legacy network boundary bridge targets runtime-native Domain operations only. It does not change tool selection, the experimental terminal override, or VS Code client fetch. Native `limitTo` enforcement in the integrated browser is deferred; existing browser controls remain unchanged. Browser/client tools, experimental terminal overrides, arbitrary extension/MCP networking, and client-run code must not be assumed to inherit the runtime's Domain enforcement. -Resolve initial account policy before choosing or activating Local. When Local is explicitly prohibited, new chats select Copilot Agent Host, including explicit or remembered Local choices. An unavailable host must produce an actionable error, never a Local fallback. Displaying an existing non-archived Local chat automatically moves its transcript and unsent draft/attachments to Copilot, without sending or copying model and permission settings. Background history loads do not migrate chats. The original is archived only after the destination opens, and archived originals remain readable without migrating again. On failure, the original remains read-only with a Move to Copilot retry action; sending, retrying requests, and queueing stay blocked. The destination uses its normal policy-constrained defaults. Removing the explicit prohibition restores normal Local eligibility without rewriting preferences. Editor inline chat's limited read/edit flow remains available as an explicit exception; this gate does not enforce runtime permission rules on those edits. +Resolve initial account policy before choosing or activating Local. When effective policy requires Agent Host, new chats select Copilot, including explicit or remembered Local choices. An unavailable host must produce an actionable error, never a Local fallback. Displaying an existing non-archived Local chat automatically moves its transcript and unsent draft/attachments to Copilot, without sending or copying model and permission settings. Background history loads do not migrate chats. The original is archived only after the destination opens, and archived originals remain readable without migrating again. On failure, the original remains read-only with a Move to Copilot retry action; sending, retrying requests, and queueing stay blocked. The destination uses its normal policy-constrained defaults. Removing the requirement restores normal Local eligibility without rewriting preferences. Removing `permissions.deny` or replacing it with a valid empty array removes its harness requirement. Legacy `ChatToolsAutoApprove` alone and personal sandbox preferences do not require Agent Host. The same request gate applies to old Local chats under a managed sandbox floor; virtual workspaces and unavailable hosts cannot silently run Local instead. Editor inline chat's limited read/edit flow remains available as an explicit exception; this gate does not enforce runtime permission rules on those edits. ## Host-Injection Lifecycle ### Approval modes -Copilot Agent Host resolves native mode restrictions before applying the legacy global approval fallback. An explicitly configured new mode restriction replaces the legacy blanket restriction; `defaultMode` alone does not. Other bridged restrictions, including the per-tool bypass lock and terminal asks, remain in force. The host publishes selectable modes and the starting selection in session config, validates client writes, and reconciles runtime refusals to the actual applied mode. Desktop and remote Copilot Agent Hosts use the same policy path. Native settings resolve where the host runs; this does not replicate desktop device policies to remote hosts. The UI must not reapply the legacy clamp to host-reported choices. Local offers Default, Allow All, and Autopilot, not Assisted. Managed permissions do not change Local harness eligibility or implement runtime modes in Local; its existing controls remain in effect. Clients retain the legacy guard until a host advertises its approval-policy report. Both standard `approvalMode` and VS Code `autoApprove` reports follow this contract. The report requires a valid approval binding. Under the legacy restriction, discover that binding before creation even without an explicit preference, and seed Manual when the host does not publish its own policy report. Host-owned approval reports are never forwarded as client configuration inputs. Explicit and remembered approval preferences are normalized after schema discovery, before prewarming a backend; they must not be irreversibly clamped before discovery. Defaults are only startup preferences; no additional durable pre-override restoration state is maintained. Restart to apply changed managed settings. A client restart refreshes selectable modes for already-loaded chats even while their SDK sessions remain disconnected, without resuming them or sending a prompt. The last runtime-reported applied mode is not overwritten by this preview. +Copilot Agent Host resolves native mode restrictions before applying the legacy global approval fallback. An explicitly configured new mode restriction replaces the legacy blanket restriction; `defaultMode` alone does not. Other bridged restrictions, including the per-tool bypass lock and terminal asks, remain in force. The host publishes selectable modes and the starting selection in session config, validates client writes, and reconciles runtime refusals to the actual applied mode. Desktop and remote Copilot Agent Hosts use the same policy path. Native settings resolve where the host runs; this does not replicate desktop device policies to remote hosts. The UI must not reapply the legacy clamp to host-reported choices. Local offers Default, Allow All, and Autopilot, not Assisted. Mode restrictions alone do not disable the Local harness or implement runtime modes in Local; its existing controls remain in effect. Clients retain the legacy guard until a host advertises its approval-policy report. Both standard `approvalMode` and VS Code `autoApprove` reports follow this contract. The report requires a valid approval binding. Under the legacy restriction, discover that binding before creation even without an explicit preference, and seed Manual when the host does not publish its own policy report. Host-owned approval reports are never forwarded as client configuration inputs. Explicit and remembered approval preferences are normalized after schema discovery, before prewarming a backend; they must not be irreversibly clamped before discovery. Defaults are only startup preferences; no additional durable pre-override restoration state is maintained. Restart to apply changed managed settings. A client restart refreshes selectable modes for already-loaded chats even while their SDK sessions remain disconnected, without resuming them or sending a prompt. The last runtime-reported applied mode is not overwritten by this preview. **Simple rule:** an explicitly configured valid new mode restriction replaces the legacy blanket mode policy on the supported Copilot Agent Host; otherwise the old policy is the fallback. `defaultMode` alone never removes restrictions. -**Potential surprise:** scenarios 6 and 8 below allow Allow All despite the old key being false. Setting a new mode restriction opts into the new group, so an unspecified mode is not implicitly restricted by the legacy key. Other managed/per-tool restrictions still apply. These rows assume all modes are supported and show selectable modes, not defaults. Local availability remains subject to normal harness selection and an explicit Local prohibition; runtime-only controls are not enforced by Local. The legacy JSON is logical policy notation, not an OS-specific deployment format. +**Potential surprise:** scenarios 6 and 8 below allow Allow All despite the old key being false. Setting a new mode restriction opts into the new group, so an unspecified mode is not implicitly restricted by the legacy key. These mode settings alone do not disable Local; its eligibility follows the harness rules above. Other managed/per-tool restrictions still apply. These rows assume all modes are supported and show selectable modes, not defaults. The legacy JSON is logical policy notation, not an OS-specific deployment format. ✅ Allowed; ❌ Blocked. diff --git a/.github/skills/policy-and-managed-settings/vscode-policy.md b/.github/skills/policy-and-managed-settings/vscode-policy.md index e1cd6bb8b5b01f..58318c0ebf4135 100644 --- a/.github/skills/policy-and-managed-settings/vscode-policy.md +++ b/.github/skills/policy-and-managed-settings/vscode-policy.md @@ -63,9 +63,9 @@ All findings are **report-only**. They do not change experiment enrollment, auto Harness selection belongs to its existing controls. `ChatEditorPreferCopilotHarness` manages `chat.editor.preferCopilotHarness`, which promotes otherwise-Local new chats to Copilot. It is not a hard lock: false does not require Local or prohibit explicit/remembered Copilot choices. A hard harness mandate would require a separate enforcement contract, not a new reaction to diagnostic uncertainty. Remote hosts, Claude, and Codex are outside this inventory's enforcement scope. -The temporary managed setting `vscode.temporaryAllowLocalHarness: false` prohibits the Local chat harness without requiring any `permissions.*` or sandbox settings. It also disables Claude and Codex through their existing integration policies. It exists only while Local remains supported and uses the existing managed-policy routing, Local request blocking, and automatic conversation migration to Copilot. Local is hidden and cannot be selected explicitly or used as a fallback when Copilot is unavailable, including in virtual workspaces. Existing transcripts are preserved by the migration flow; editor inline chat retains its existing exemption. Only effective boolean `false` adds these restrictions; true, absence, and malformed values leave existing policy and rollout behavior unchanged. Neither value contributes general managed-settings governance for unrelated presence-gated features; other managed settings keep their existing effects. This is a managed-settings-only control, watched by native MDM and consumed through `IManagedSettingsService` and the Claude/Codex policy projections, not a user/workspace configuration setting. It retains per-key native MDM > server > file precedence. +The temporary managed setting `vscode.temporaryAllowLocalHarness: false` prohibits the Local chat harness without requiring any `permissions.*` or sandbox settings. It also disables Claude and Codex through their existing integration policies. It exists only while Local remains supported and uses the existing managed-policy routing, Local request blocking, and automatic conversation migration to Copilot. Local remains visible as a disabled picker option with a policy explanation and learn-more link, and cannot be selected explicitly or used as a fallback when Copilot is unavailable, including in virtual workspaces. Existing transcripts are preserved by the migration flow; editor inline chat retains its existing exemption. Only effective boolean `false` adds these restrictions; true, absence, and malformed values leave existing policy and rollout behavior unchanged. Neither value contributes general managed-settings governance for unrelated presence-gated features; other managed settings keep their existing effects. This is a managed-settings-only control, watched by native MDM and consumed through `IManagedSettingsService` and the Claude/Codex policy projections, not a user/workspace configuration setting. It retains per-key native MDM > server > file precedence. -The temporary enterprise escape hatch `vscode.temporaryDefaultToLocalHarness: true` projects to the hidden, policy-only `chat.temporaryDefaultToLocalHarness` setting. Unless `vscode.temporaryAllowLocalHarness` is false, it keeps Local visible and usable and suppresses both Copilot-default and Copilot-preference rollout settings. Managed sandbox and permission settings do not independently prohibit Local or force selection of Copilot. Explicit, remembered, and current non-Local choices remain unchanged, as do existing conversations. Local cannot enforce the runtime-only sandbox or permission rules; those managed values still apply to Copilot runtime sessions. Only an effective managed/policy `true` enables the escape hatch; ordinary user/workspace settings cannot enable it. False or removal restores normal routing. Managed delivery retains per-key native MDM > server > file precedence. Callers must treat `requiresCopilotAgentHost` as the effective routing decision rather than independently reapplying the escape hatch. +The temporary enterprise escape hatch `vscode.temporaryDefaultToLocalHarness: true` projects to the hidden, policy-only `chat.temporaryDefaultToLocalHarness` setting. Unless `vscode.temporaryAllowLocalHarness` is false, it keeps Local visible and usable and suppresses both Copilot-default and Copilot-preference rollout settings, including automatic routing and Local request blocking caused by managed sandbox or permission settings. Explicit, remembered, and current non-Local choices remain unchanged, as do existing conversations. Local cannot enforce the runtime-only sandbox or permission rules; those managed values still apply to Copilot runtime sessions. Only an effective managed/policy `true` enables the escape hatch; ordinary user/workspace settings cannot enable it. False or removal restores normal routing. Managed delivery retains per-key native MDM > server > file precedence. Callers must treat `requiresCopilotAgentHost` as the effective routing decision rather than independently reapplying the escape hatch. Only the non-applied policy inventory is additionally collapsed. Existing account, transport, runtime, and authentication sections remain visible in their original layout, including their own raw-value disclosures. Unconfigured policies must not appear as current gaps. A clean self-check is not an attestation of runtime enforcement; user settings and unprojected runtime managed settings are outside this check. diff --git a/.github/workflows/chat-perf.yml b/.github/workflows/chat-perf.yml index ba94b89cf1965c..4b08ab31e6a712 100644 --- a/.github/workflows/chat-perf.yml +++ b/.github/workflows/chat-perf.yml @@ -156,7 +156,7 @@ jobs: run: node build/lib/preLaunch.ts - name: Cache Electron - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/electron key: electron-${{ runner.os }}-${{ hashFiles('.nvmrc', 'package.json') }} @@ -165,7 +165,7 @@ jobs: run: npx playwright install chromium - name: Cache Playwright - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('package.json') }} diff --git a/build/azure-pipelines/product-build-template.yml b/build/azure-pipelines/product-build-template.yml index 87e64dda07abe9..57700abec5f855 100644 --- a/build/azure-pipelines/product-build-template.yml +++ b/build/azure-pipelines/product-build-template.yml @@ -756,32 +756,32 @@ stages: container: centos arch: arm64 - # Debian 11 + # Debian 13 - ${{ if eq(parameters.VSCODE_BUILD_LINUX, true) }}: - template: common/sanity-tests.yml@self parameters: - name: debian_11_amd64 - displayName: Debian 11 amd64 + name: debian_13_amd64 + displayName: Debian 13 amd64 poolName: 1es-ubuntu-22.04-x64 - container: debian-11 + container: debian-13 arch: amd64 - ${{ if eq(parameters.VSCODE_BUILD_LINUX_ARMHF, true) }}: - template: common/sanity-tests.yml@self parameters: - name: debian_11_arm32 - displayName: Debian 11 arm32 + name: debian_13_arm32 + displayName: Debian 13 arm32 poolName: 1es-azure-linux-3-arm64 - container: debian-11 + container: debian-13 arch: arm - ${{ if eq(parameters.VSCODE_BUILD_LINUX_ARM64, true) }}: - template: common/sanity-tests.yml@self parameters: - name: debian_11_arm64 - displayName: Debian 11 arm64 + name: debian_13_arm64 + displayName: Debian 13 arm64 poolName: 1es-azure-linux-3-arm64 - container: debian-11 + container: debian-13 arch: arm64 # Debian 12 diff --git a/build/azure-pipelines/product-build.yml b/build/azure-pipelines/product-build.yml index 6faef8ecb6ff45..98d128a5a96743 100644 --- a/build/azure-pipelines/product-build.yml +++ b/build/azure-pipelines/product-build.yml @@ -1039,32 +1039,32 @@ extends: container: centos arch: arm64 - # Debian 11 + # Debian 13 - ${{ if eq(parameters.VSCODE_BUILD_LINUX, true) }}: - template: build/azure-pipelines/common/sanity-tests.yml@self parameters: - name: debian_11_amd64 - displayName: Debian 11 amd64 + name: debian_13_amd64 + displayName: Debian 13 amd64 poolName: 1es-ubuntu-22.04-x64 - container: debian-11 + container: debian-13 arch: amd64 - ${{ if eq(parameters.VSCODE_BUILD_LINUX_ARMHF, true) }}: - template: build/azure-pipelines/common/sanity-tests.yml@self parameters: - name: debian_11_arm32 - displayName: Debian 11 arm32 + name: debian_13_arm32 + displayName: Debian 13 arm32 poolName: 1es-azure-linux-3-arm64 - container: debian-11 + container: debian-13 arch: arm - ${{ if eq(parameters.VSCODE_BUILD_LINUX_ARM64, true) }}: - template: build/azure-pipelines/common/sanity-tests.yml@self parameters: - name: debian_11_arm64 - displayName: Debian 11 arm64 + name: debian_13_arm64 + displayName: Debian 13 arm64 poolName: 1es-azure-linux-3-arm64 - container: debian-11 + container: debian-13 arch: arm64 # Debian 12 diff --git a/build/azure-pipelines/product-sanity-tests.yml b/build/azure-pipelines/product-sanity-tests.yml index 0af38d689ee239..dea26cbc5860f2 100644 --- a/build/azure-pipelines/product-sanity-tests.yml +++ b/build/azure-pipelines/product-sanity-tests.yml @@ -128,29 +128,29 @@ extends: container: centos arch: arm64 - # Debian 11 + # Debian 13 - template: build/azure-pipelines/common/sanity-tests.yml@self parameters: - name: debian_11_amd64 - displayName: Debian 11 amd64 + name: debian_13_amd64 + displayName: Debian 13 amd64 poolName: 1es-ubuntu-22.04-x64 - container: debian-11 + container: debian-13 arch: amd64 - template: build/azure-pipelines/common/sanity-tests.yml@self parameters: - name: debian_11_arm32 - displayName: Debian 11 arm32 + name: debian_13_arm32 + displayName: Debian 13 arm32 poolName: 1es-azure-linux-3-arm64 - container: debian-11 + container: debian-13 arch: arm - template: build/azure-pipelines/common/sanity-tests.yml@self parameters: - name: debian_11_arm64 - displayName: Debian 11 arm64 + name: debian_13_arm64 + displayName: Debian 13 arm64 poolName: 1es-azure-linux-3-arm64 - container: debian-11 + container: debian-13 arch: arm64 # Debian 12 diff --git a/build/lib/policies/policyData.jsonc b/build/lib/policies/policyData.jsonc index 9b5ef8d3e6ec27..e2326104721c54 100644 --- a/build/lib/policies/policyData.jsonc +++ b/build/lib/policies/policyData.jsonc @@ -808,7 +808,7 @@ "localization": { "description": { "key": "chat.temporaryDefaultToLocalHarness.policy", - "value": "Temporarily keeps Local available and prevents automatic selection of the Copilot harness, overriding rollout settings. Local does not enforce runtime-only sandbox and permission settings." + "value": "Temporarily keeps Local available and prevents automatic selection of the Copilot harness, overriding rollout settings and managed sandbox and permission routing. Local does not enforce runtime-only sandbox and permission settings." } }, "type": "boolean", diff --git a/build/lib/test/dictationRuntime.test.ts b/build/lib/test/dictationRuntime.test.ts index 50ffeee7cde79b..b6ce5b5f5946a3 100644 --- a/build/lib/test/dictationRuntime.test.ts +++ b/build/lib/test/dictationRuntime.test.ts @@ -17,15 +17,15 @@ import { } from '../../dictation-runtime/nuget.ts'; const dependencies: IFoundryDependencyVersions = { - onnxruntime: { version: '1.30.0' }, - 'onnxruntime-genai': { version: '0.17.1' }, + onnxruntime: { version: '1.28.0' }, + 'onnxruntime-genai': { version: '0.15.2' }, }; suite('dictation runtime', () => { test('uses the Foundry Local 2.x dependency artifact set', () => { assert.deepStrictEqual(getStandardArtifacts(dependencies), [ - { name: 'Microsoft.ML.OnnxRuntime', version: '1.30.0' }, - { name: 'Microsoft.ML.OnnxRuntimeGenAI.Foundry', version: '0.17.1' }, + { name: 'Microsoft.ML.OnnxRuntime', version: '1.28.0' }, + { name: 'Microsoft.ML.OnnxRuntimeGenAI.Foundry', version: '0.15.2' }, ]); }); @@ -63,7 +63,7 @@ suite('dictation runtime', () => { } await assert.rejects( fetchDependencyLibraries(target, getStandardArtifacts(dependencies), targetDirectory, { feeds: [] }), - /Failed to download Microsoft\.ML\.OnnxRuntime 1\.30\.0 from any feed/, + /Failed to download Microsoft\.ML\.OnnxRuntime 1\.28\.0 from any feed/, ); } } finally { diff --git a/copilot_shim/REQUIREMENTS.md b/copilot_shim/REQUIREMENTS.md index a5feb8304d7880..f1d4a1d5efbd73 100644 --- a/copilot_shim/REQUIREMENTS.md +++ b/copilot_shim/REQUIREMENTS.md @@ -112,6 +112,25 @@ usable after a failed refresh, but the extension does not advertise a folder with surviving legacy wrappers that could shadow the native executable. The terminal link provider and Copilot session data are unchanged. +### Terminal PATH locality and recovery + +Authority matching follows terminal backend routing and is case-insensitive. Variable names follow the terminal's OS: Windows treats all PATH spellings alike; on macOS and Linux only `PATH` is subject to locality filtering, while `Path` and `path` remain distinct ordinary variables. + +The extension's terminal PATH contribution is applied only to terminals on the +same computer as the producing extension host. A local contribution applies to +local terminals, including local terminals in remote windows; a remote +contribution applies only to terminals on that same remote authority. Other +environment variables retain their existing cross-host sharing behavior, and +the terminal's base PATH and independently installed commands are not changed. + +Saved PATH contributions with an unknown origin, or an origin on another +computer, are absent from the extension's initial cached collection until the +extension refreshes them. Correctly tagged same-computer saved contributions +remain usable before activation. This cache migration is distinct from remote +process recovery: a recovered pre-fix remote process retains its complete +historical environment until normal relaunch, when the current filtered +contribution is applied. + ### Ownership VS Code owns only the shim, the launcher that gets Copilot CLI onto the device. diff --git a/extensions/copilot/assets/prompts/skills/migrate-customizations/SKILL.md b/extensions/copilot/assets/prompts/skills/migrate-customizations/SKILL.md index a75be3fa75603b..13d6c1b0671357 100644 --- a/extensions/copilot/assets/prompts/skills/migrate-customizations/SKILL.md +++ b/extensions/copilot/assets/prompts/skills/migrate-customizations/SKILL.md @@ -1,6 +1,6 @@ --- name: migrate-customizations -description: Guide a safe, reversible migration of VS Code local agent customizations when launched from the Agent Customizations Migrations page with VS Code-generated migration context. +description: Guide a safe, reversible migration of VS Code agent customizations when launched from the Agent Customizations Migrations page with VS Code-generated migration context. argument-hint: Launch from Agent Customizations > Migrations > Migrate with Agent user-invocable: true disable-model-invocation: true @@ -16,7 +16,6 @@ Before doing anything else, verify that the invoking prompt includes all of the - Selected harness - Migration telemetry flow -- Recovery bundle folder and filesystem path - Customizations that need migration - Valid target folders reported by the selected harness @@ -26,18 +25,14 @@ Read [migration techniques](./references/migration-techniques.md) before proposi ## Safety Contract -1. Never modify a customization before creating a recovery bundle. -2. Use the recovery bundle folder supplied in the invoking prompt. VS Code created and is watching this folder; do not move it or substitute another location. -3. In the recovery bundle, create: - - `migration-log.md`, containing the selected harness, scope, timestamps, source and destination paths, commands or tools used, validation results, and every user decision. - - `backups/`, containing a byte-for-byte copy of every file before its first modification or deletion. - - `restore.md`, containing exact steps for restoring the backups and reverting newly created files. - - `migration-results.json`, containing only the privacy-safe aggregate schema described in Finish. VS Code watches this file for the final migration outcome. -4. Append to the log after every attempted operation, including failures and rollbacks. -5. Approval to migrate a group authorizes destination writes only; it does not authorize deleting source files, removing source MCP entries, or clearing location settings. Preserve sources by default. After the destination is validated, ask separately for explicit approval for each kind of cleanup. If runtime validation is not possible, explain what was and was not verified, keep the source, and ask whether to defer cleanup. -6. Stop on an unexpected source change, destination conflict, invalid configuration, failed backup, or failed validation. Explain the problem instead of guessing. -7. Never include customization names, paths, contents, MCP configuration, or other user data in telemetry. -8. Classify each inventory item exactly once using its supplied category and scope. Do not offer the same item again in another group; if groups overlap, explain the overlap and use the item's inventory category to decide where it is handled. +1. VS Code created and verified a private recovery bundle before starting this workflow. Do not search for, inspect, modify, move, or replace its manifest or backups. +2. The recovery bundle restores original sources only. It does not remove or revert destination files created or changed during migration. +3. Call `report_customization_migration` exactly once using the privacy-safe aggregate schema described in Finish. This internal tool is available only in the migration chat and does not require filesystem access. +4. Approval to migrate a group authorizes destination writes only; it does not authorize deleting source files, removing source MCP entries, or clearing location settings. Preserve sources by default. After the destination is validated, ask separately for explicit approval for each kind of cleanup. If runtime validation is not possible, explain what was and was not verified, keep the source, and ask whether to defer cleanup. +5. Stop on an unexpected source change, destination conflict, invalid configuration, or failed validation. Explain the problem instead of guessing. +6. Never include customization names, paths, contents, MCP configuration, or other user data in telemetry. +7. Classify each inventory item exactly once using its supplied category and scope. Do not offer the same item again in another group; if groups overlap, explain the overlap and use the item's inventory category to decide where it is handled. +8. When using `ask_user`, keep the question short and write it as a single sentence so it remains readable in the chat viewer. ## Workflow @@ -76,7 +71,7 @@ Work one group at a time. Do not request approval for all writes at once. ### 3. Migrate Prompt Files to Skills -Explain the compatibility reason for converting each prompt to a skill. Convert each selected prompt into a skill directory with a `SKILL.md`, preserving supported name, description, argument guidance, invocation semantics, and body content. Add `disable-model-invocation: true` when needed to preserve an explicit-invocation-only prompt. Record unsupported frontmatter in the log for review; do not silently drop behavior. +Explain the compatibility reason for converting each prompt to a skill. Convert each selected prompt into a skill directory with a `SKILL.md`, preserving supported name, description, argument guidance, invocation semantics, and body content. Add `disable-model-invocation: true` when needed to preserve an explicit-invocation-only prompt. Report unsupported frontmatter for review; do not silently drop behavior. Validate that each skill has valid frontmatter, a meaningful description, and a folder name that matches the skill name. Distinguish structural validation from runtime validation: test discovery and representative invocation in the selected destination harness when possible. A file inspection or a test against a different harness is not proof that the destination harness loads the skill. If runtime validation requires a new session or is otherwise unavailable, say so, leave the source in place, and defer cleanup unless the user explicitly chooses otherwise after hearing the limitation. @@ -101,7 +96,7 @@ Handle only items reported as `configuredLocations` and not already handled in a ### 7. Finish -Before the final summary, write `migration-results.json` in the recovery bundle: +Before the final summary, call `report_customization_migration` with: ```json { @@ -127,11 +122,11 @@ Aggregate results by: - customization type: `agent`, `instructions`, `skill`, or `mcpServer`; - outcome: `migrated`, `skipped`, or `failed`. -The total reported for each category, scope, and customization type must not exceed the inventory supplied by VS Code. The result file must never include names, paths, contents, configuration values, error messages, or other user data. +The total reported for each category, scope, and customization type must not exceed the inventory supplied by VS Code. The tool input must never include names, paths, contents, configuration values, error messages, or other user data. -Write the validated JSON to the exact recovery bundle supplied by VS Code. Do not write a result file when no migration telemetry flow was supplied. +Do not print this payload in the chat response and do not write it to a file. If the tool call fails, explain the failure and retry once with a corrected payload. Do not claim that VS Code recorded the outcome unless the tool succeeds. Do not call the tool when no migration telemetry flow was supplied. -If the user stops the workflow, set `cancelled` to `true`, record completed work with its actual outcome, mark the remaining inventory as `skipped`, and still validate and write the result file before the final cancellation summary. +If the user stops the workflow, set `cancelled` to `true`, record completed work with its actual outcome, mark the remaining inventory as `skipped`, and still call the tool before the final cancellation summary. Summarize: @@ -139,7 +134,7 @@ Summarize: - created, modified, deleted, and retained files; - validation performed; - cleanup still pending; -- recovery bundle and restoration instructions; +- recovery bundle, noting that VS Code can restore original sources but does not revert destination files; - pull request URL when one was requested. Keep the recovery bundle until the user confirms the migrated customizations work. diff --git a/extensions/copilot/package-lock.json b/extensions/copilot/package-lock.json index 0d7a5862f9982e..c304a0fae04ef8 100644 --- a/extensions/copilot/package-lock.json +++ b/extensions/copilot/package-lock.json @@ -1,12 +1,12 @@ { "name": "copilot-chat", - "version": "0.71.0", + "version": "0.72.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "copilot-chat", - "version": "0.71.0", + "version": "0.72.0", "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { @@ -143,7 +143,7 @@ "engines": { "node": ">=22.14.0", "npm": ">=9.0.0", - "vscode": "^1.143.0" + "vscode": "^1.144.0" } }, "node_modules/@anthropic-ai/sdk": { diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 70b8e51e5a4873..cdb978e76adbbf 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -2,7 +2,7 @@ "name": "copilot-chat", "displayName": "GitHub Copilot", "description": "AI chat features powered by Copilot", - "version": "0.71.0", + "version": "0.72.0", "build": "1", "completionsCoreVersion": "1.378.1799", "internalLargeStorageAriaKey": "ec712b3202c5462fb6877acae7f1f9d7-c19ad55e-3e3c-4f99-984b-827f6d95bd9e-6917", @@ -22,7 +22,7 @@ "icon": "assets/copilot.png", "pricing": "Trial", "engines": { - "vscode": "^1.143.0", + "vscode": "^1.144.0", "npm": ">=9.0.0", "node": ">=22.14.0" }, diff --git a/extensions/copilot/src/platform/promptFiles/test/node/builtinSkills.spec.ts b/extensions/copilot/src/platform/promptFiles/test/node/builtinSkills.spec.ts index 3f3ca3ab90d30b..7dfa98c26679f8 100644 --- a/extensions/copilot/src/platform/promptFiles/test/node/builtinSkills.spec.ts +++ b/extensions/copilot/src/platform/promptFiles/test/node/builtinSkills.spec.ts @@ -71,8 +71,6 @@ suite('built-in skills', () => { path.join(copilotRoot, 'assets', 'prompts', 'skills', 'migrate-customizations', 'SKILL.md'), 'utf-8', ); - expect(skill).not.toContain('report_customization_migration'); - expect({ contributions, requiredContent: [ @@ -82,10 +80,10 @@ suite('built-in skills', () => { 'Agent Customizations > Migrations', 'Do not ask the user to manually reconstruct or paste the missing context', './references/migration-techniques.md', - 'migration-log.md', - 'restore.md', + 'VS Code created and verified a private recovery bundle', + 'When using `ask_user`, keep the question short and write it as a single sentence', 'pull request', - 'migration-results.json', + 'report_customization_migration', ].filter(anchor => skill.includes(anchor)), }).toEqual({ contributions: [{ @@ -99,10 +97,10 @@ suite('built-in skills', () => { 'Agent Customizations > Migrations', 'Do not ask the user to manually reconstruct or paste the missing context', './references/migration-techniques.md', - 'migration-log.md', - 'restore.md', + 'VS Code created and verified a private recovery bundle', + 'When using `ask_user`, keep the question short and write it as a single sentence', 'pull request', - 'migration-results.json', + 'report_customization_migration', ], }); }); diff --git a/package-lock.json b/package-lock.json index 7493628ed3ca89..e781e8f9d09f5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "code-oss-dev", - "version": "1.143.0", + "version": "1.144.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "code-oss-dev", - "version": "1.143.0", + "version": "1.144.0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -23,7 +23,7 @@ "@microsoft/dev-tunnels-ssh-tcp": "^3.12.22", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", - "@vscode/codicons": "^0.0.46-44", + "@vscode/codicons": "^0.0.46-46", "@vscode/copilot-api": "^0.5.2", "@vscode/deviceid": "^0.1.1", "@vscode/diff": "0.0.2-7", @@ -55,7 +55,7 @@ "@xterm/xterm": "^6.1.0-beta.304", "chrome-remote-interface": "^0.33.0", "detect-libc": "^2.1.2", - "foundry-local-sdk": "2.1.0", + "foundry-local-sdk": "2.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "jschardet": "3.1.4", @@ -4293,9 +4293,9 @@ } }, "node_modules/@vscode/codicons": { - "version": "0.0.46-44", - "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-44.tgz", - "integrity": "sha512-Oy9BsJ0Yr4cQc8vTgqY4F8vXsByIwOfXpGxnWKIwaJ6V9twR35C96uIIHPxNDW64oxQpx8oRR+9L7+Y7oPp+8w==", + "version": "0.0.46-46", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-46.tgz", + "integrity": "sha512-5P6z/falKoU3upoWeCcgjMV7HYJ1dQE0F6cXViLKIRe+l4JvVjQXmL0LetwUjVRV0eh7yXfpgIoDrdY2VadNoQ==", "license": "CC-BY-4.0" }, "node_modules/@vscode/component-explorer": { @@ -10168,9 +10168,9 @@ } }, "node_modules/foundry-local-sdk": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/foundry-local-sdk/-/foundry-local-sdk-2.1.0.tgz", - "integrity": "sha512-sUgF+O7uHGVj3bInufe9nmj2SDry06dKGfVLpg9Y1TPPk/hxZEDAk2QPtU13eGZ0k7SIP3OOZFTkmvbtT+TPrw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/foundry-local-sdk/-/foundry-local-sdk-2.0.1.tgz", + "integrity": "sha512-5TwBhKypAGEeezfvluiNlWPVIBX82MW9riqerlROKEzODRYxmzo13fKQ2qEDkVBbioV2PWP4LFFzUqk/NrKjcw==", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 1dc7612f395f49..43894f15eea55b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "code-oss-dev", - "version": "1.143.0", + "version": "1.144.0", "distro": "f208aaeb1f93d9d6b44034abbb2a911f3b9ef471", "author": { "name": "Microsoft Corporation" @@ -113,7 +113,7 @@ "@microsoft/dev-tunnels-ssh-tcp": "^3.12.22", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", - "@vscode/codicons": "^0.0.46-44", + "@vscode/codicons": "^0.0.46-46", "@vscode/copilot-api": "^0.5.2", "@vscode/deviceid": "^0.1.1", "@vscode/diff": "0.0.2-7", @@ -145,7 +145,7 @@ "@xterm/xterm": "^6.1.0-beta.304", "chrome-remote-interface": "^0.33.0", "detect-libc": "^2.1.2", - "foundry-local-sdk": "2.1.0", + "foundry-local-sdk": "2.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "jschardet": "3.1.4", @@ -298,7 +298,7 @@ "allowScripts": { "bufferutil@4.1.0": true, "cpu-features": false, - "foundry-local-sdk@2.1.0": true, + "foundry-local-sdk@2.0.1": true, "kerberos@2.1.1": true, "koffi@3.2.1": false, "native-keymap@3.3.9": true, diff --git a/remote/web/package-lock.json b/remote/web/package-lock.json index 44b9a4ae340d96..80d09c3d5841f6 100644 --- a/remote/web/package-lock.json +++ b/remote/web/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@vscode/codicons": "^0.0.46-44", + "@vscode/codicons": "^0.0.46-46", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.1", "@vscode/vscode-languagedetection": "1.0.23", @@ -73,9 +73,9 @@ "integrity": "sha512-n1VPsljTSkthsAFYdiWfC+DKzK2WwcRp83Y1YAqdX552BstvsDjft9YXppjUzp11BPsapDoO1LDgrDB0XVsfNQ==" }, "node_modules/@vscode/codicons": { - "version": "0.0.46-44", - "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-44.tgz", - "integrity": "sha512-Oy9BsJ0Yr4cQc8vTgqY4F8vXsByIwOfXpGxnWKIwaJ6V9twR35C96uIIHPxNDW64oxQpx8oRR+9L7+Y7oPp+8w==", + "version": "0.0.46-46", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-46.tgz", + "integrity": "sha512-5P6z/falKoU3upoWeCcgjMV7HYJ1dQE0F6cXViLKIRe+l4JvVjQXmL0LetwUjVRV0eh7yXfpgIoDrdY2VadNoQ==", "license": "CC-BY-4.0" }, "node_modules/@vscode/iconv-lite-umd": { diff --git a/remote/web/package.json b/remote/web/package.json index 4659a40bd13183..e36cba6421a7f2 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -5,7 +5,7 @@ "dependencies": { "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@vscode/codicons": "^0.0.46-44", + "@vscode/codicons": "^0.0.46-46", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.1", "@vscode/vscode-languagedetection": "1.0.23", diff --git a/scripts/chat-simulation/common/mock-llm-server.ts b/scripts/chat-simulation/common/mock-llm-server.ts index 51cdc1e7bb0803..e301c2e1a0d2e4 100644 --- a/scripts/chat-simulation/common/mock-llm-server.ts +++ b/scripts/chat-simulation/common/mock-llm-server.ts @@ -56,14 +56,23 @@ function _indentVerbose(text: string): string { interface StreamChunk { content: string; delayMs: number; + /** Optional test-controlled barrier before this chunk is sent. */ + waitFor?: Promise; } type ScenarioToolCallArguments = Record | ((request: readonly any[]) => Record); -interface ScenarioToolCall { +type ScenarioToolCall = { toolNamePattern: RegExp; + namespace?: string; + kind?: 'function'; arguments: ScenarioToolCallArguments; -} +} | { + toolNamePattern: RegExp; + namespace?: string; + kind: 'custom'; + input: string; +}; /** * A single turn in a multi-turn scenario. @@ -916,7 +925,7 @@ function resolveCurrentTurn(turns: ScenarioTurn[], messages: any[]): { turn: Mod function findLastToolResult(items: any[]): any { for (let i = items.length - 1; i >= 0; i--) { const item = items[i]; - if (item?.role === 'tool' || item?.type === 'function_call_output') { + if (item?.role === 'tool' || item?.type === 'function_call_output' || item?.type === 'custom_tool_call_output') { return item; } if (Array.isArray(item?.content)) { @@ -1067,6 +1076,7 @@ async function streamContent(res: import('http').ServerResponse, chunks: StreamC res.write(`data: ${JSON.stringify(makeInitialChunk())}\n\n`); for (const chunk of chunks) { + await chunk.waitFor; if (chunk.delayMs > 0) { await sleep(chunk.delayMs); } res.write(`data: ${JSON.stringify(makeChunk(chunk.content, 0, false))}\n\n`); } @@ -1215,13 +1225,17 @@ async function sendResponsesNonStreaming( toolName = call.toolNamePattern.source.replace(/[\\.|?*+^${}()\[\]]/g, ''); } const callId = `call_${scenarioId}_${i}_${Date.now()}`; - const argsJson = JSON.stringify(resolveScenarioToolCallArguments(call.arguments, input)); + if (call.kind === 'custom') { + return { id: `ctc_${callId}`, type: 'custom_tool_call', status: 'completed', call_id: callId, name: toolName, namespace: call.namespace, input: call.input }; + } + const argsJson = JSON.stringify(resolveScenarioToolCallArguments(call, input)); return { id: `fc_${callId}`, type: 'function_call', status: 'completed', call_id: callId, name: toolName, + namespace: call.namespace, arguments: argsJson, }; }); @@ -1288,8 +1302,8 @@ async function sendResponsesNonStreaming( /** * Count completed assistant turns in a Responses API `input` array, after the * last user message carrying a `[scenario:X]` tag. Consecutive assistant - * output items (`role === 'assistant'` messages or `type === 'function_call'` - * items) are grouped into a single turn so multi-tool-call turns count once. + * output items (assistant messages, function calls, or custom tool calls) + * are grouped into a single turn so multi-tool-call turns count once. */ function countCompletedResponsesApiModelTurns(input: any[]): number { let scenarioIdx = -1; @@ -1312,7 +1326,7 @@ function countCompletedResponsesApiModelTurns(input: any[]): number { const startIdx = scenarioIdx >= 0 ? scenarioIdx + 1 : 0; for (let i = startIdx; i < input.length; i++) { const item = input[i]; - const isAssistantOutput = item.role === 'assistant' || item.type === 'function_call'; + const isAssistantOutput = item.role === 'assistant' || item.type === 'function_call' || item.type === 'custom_tool_call'; if (isAssistantOutput) { if (!inAssistantBlock) { turns++; @@ -1337,7 +1351,7 @@ function resolveCurrentResponsesApiTurn(turns: ScenarioTurn[], input: any[]): { /** * Stream tool calls as Responses API SSE events. Emits one - * `function_call` output item per requested tool call. + * function or custom-tool output item per requested tool call. */ async function streamResponsesApiToolCalls( res: import('http').ServerResponse, @@ -1386,15 +1400,19 @@ async function streamResponsesApiToolCalls( const callId = `call_${scenarioId}_${i}_${Date.now()}`; const itemId = `fc_${callId}`; - const argsJson = JSON.stringify(resolveScenarioToolCallArguments(call.arguments, request)); + const custom = call.kind === 'custom'; + const argumentField = custom ? 'input' : 'arguments'; + const eventName = custom ? 'custom_tool_call_input' : 'function_call_arguments'; + const argsJson = custom ? call.input : JSON.stringify(resolveScenarioToolCallArguments(call, request)); const item = { id: itemId, - type: 'function_call', + type: custom ? 'custom_tool_call' : 'function_call', status: 'in_progress', call_id: callId, name: toolName, - arguments: '', + namespace: call.namespace, + [argumentField]: '', }; res.write(`event: response.output_item.added\ndata: ${JSON.stringify({ @@ -1404,23 +1422,23 @@ async function streamResponsesApiToolCalls( item, })}\n\n`); - res.write(`event: response.function_call_arguments.delta\ndata: ${JSON.stringify({ - type: 'response.function_call_arguments.delta', + res.write(`event: response.${eventName}.delta\ndata: ${JSON.stringify({ + type: `response.${eventName}.delta`, sequence_number: nextSeq(), item_id: itemId, output_index: i, delta: argsJson, })}\n\n`); - res.write(`event: response.function_call_arguments.done\ndata: ${JSON.stringify({ - type: 'response.function_call_arguments.done', + res.write(`event: response.${eventName}.done\ndata: ${JSON.stringify({ + type: `response.${eventName}.done`, sequence_number: nextSeq(), item_id: itemId, output_index: i, - arguments: argsJson, + [argumentField]: argsJson, })}\n\n`); - const doneItem = { ...item, status: 'completed', arguments: argsJson }; + const doneItem = { ...item, status: 'completed', [argumentField]: argsJson }; finalOutput.push(doneItem); res.write(`event: response.output_item.done\ndata: ${JSON.stringify({ @@ -1504,6 +1522,7 @@ async function streamResponsesContent(res: import('http').ServerResponse, chunks // 4. Stream text deltas let fullText = ''; for (const chunk of chunks) { + await chunk.waitFor; if (chunk.delayMs > 0) { await sleep(chunk.delayMs); } fullText += chunk.content; res.write(`data: ${JSON.stringify({ @@ -1631,6 +1650,7 @@ async function streamAnthropicContent(res: import('http').ServerResponse, chunks let totalOutputTokens = 0; for (const chunk of chunks) { + await chunk.waitFor; if (chunk.delayMs > 0) { await sleep(chunk.delayMs); } writeAnthropicEvent(res, 'content_block_delta', { index: 0, @@ -1822,7 +1842,7 @@ async function streamAnthropicToolCalls( content_block: { type: 'tool_use', id: callId, name: toolName, input: {} }, }); - const argsJson = JSON.stringify(resolveScenarioToolCallArguments(call.arguments, request)); + const argsJson = JSON.stringify(resolveScenarioToolCallArguments(call, request)); const fragmentSize = Math.max(20, Math.ceil(argsJson.length / 4)); for (let pos = 0; pos < argsJson.length; pos += fragmentSize) { const fragment = argsJson.slice(pos, pos + fragmentSize); @@ -1863,6 +1883,7 @@ async function streamThinkingThenContent( // Stream thinking text for (const chunk of thinkingChunks) { + await chunk.waitFor; if (chunk.delayMs > 0) { await sleep(chunk.delayMs); } res.write(`data: ${JSON.stringify(makeThinkingChunk(chunk.content))}\n\n`); } @@ -1874,6 +1895,7 @@ async function streamThinkingThenContent( // Stream content for (const chunk of contentChunks) { + await chunk.waitFor; if (chunk.delayMs > 0) { await sleep(chunk.delayMs); } res.write(`data: ${JSON.stringify(makeChunk(chunk.content, 0, false))}\n\n`); } @@ -1914,7 +1936,7 @@ async function streamToolCalls( res.write(`data: ${JSON.stringify(makeToolCallStartChunk(i, callId, toolName))}\n\n`); await sleep(10); - const argsJson = JSON.stringify(resolveScenarioToolCallArguments(call.arguments, request)); + const argsJson = JSON.stringify(resolveScenarioToolCallArguments(call, request)); const fragmentSize = Math.max(20, Math.ceil(argsJson.length / 4)); for (let pos = 0; pos < argsJson.length; pos += fragmentSize) { const fragment = argsJson.slice(pos, pos + fragmentSize); @@ -1928,8 +1950,11 @@ async function streamToolCalls( res.end(); } -function resolveScenarioToolCallArguments(argumentsOrResolver: ScenarioToolCallArguments, request: readonly any[]): Record { - return typeof argumentsOrResolver === 'function' ? argumentsOrResolver(request) : argumentsOrResolver; +function resolveScenarioToolCallArguments(call: ScenarioToolCall, request: readonly any[]): Record { + if (call.kind === 'custom') { + throw new Error('Custom tool calls require the Responses API'); + } + return typeof call.arguments === 'function' ? call.arguments(request) : call.arguments; } interface MockLlmServerHandle { diff --git a/src/vs/base/common/codiconsLibrary.ts b/src/vs/base/common/codiconsLibrary.ts index c9f29b29d84ae5..2484ff79f86f39 100644 --- a/src/vs/base/common/codiconsLibrary.ts +++ b/src/vs/base/common/codiconsLibrary.ts @@ -772,4 +772,5 @@ export const codiconsLibrary = { gripperCorner: register('gripper-corner', 0xecf6), meteredConnection: register('metered-connection', 0xecf7), reportQuestion: register('report-question', 0xecf8), + trophy: register('trophy', 0xecf9), } as const; diff --git a/src/vs/editor/browser/controller/editContext/clipboardUtils.ts b/src/vs/editor/browser/controller/editContext/clipboardUtils.ts index 28a1f58084fc8b..f9117e9321613b 100644 --- a/src/vs/editor/browser/controller/editContext/clipboardUtils.ts +++ b/src/vs/editor/browser/controller/editContext/clipboardUtils.ts @@ -11,6 +11,7 @@ import { ILogService } from '../../../../platform/log/common/log.js'; import { EditorOption } from '../../../common/config/editorOptions.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { VSDataTransfer } from '../../../../base/common/dataTransfer.js'; +import { getPathForFile } from '../../../../platform/dnd/browser/dnd.js'; import { toExternalVSDataTransfer } from '../../dataTransfer.js'; export function generateDataToCopyAndStoreInMemory(viewModel: IViewModel, id: string | undefined, isFirefox: boolean): { dataToCopy: ClipboardDataToCopy; metadata: ClipboardStoredMetadata } { @@ -133,7 +134,7 @@ interface InMemoryClipboardMetadata { const ClipboardEventUtils = { - getTextData(clipboardData: IReadableClipboardData | DataTransfer): [string, ClipboardStoredMetadata | null] { + getTextData(clipboardData: IReadableClipboardData | DataTransfer, getFilePath: (file: File) => string | undefined): [string, ClipboardStoredMetadata | null] { const text = clipboardData.getData(Mimes.text); let metadata: ClipboardStoredMetadata | null = null; const rawmetadata = clipboardData.getData('vscode-editor-data'); @@ -148,9 +149,11 @@ const ClipboardEventUtils = { } } if (text.length === 0 && metadata === null && clipboardData.files.length > 0) { - // no textual data pasted, generate text from file names + // No textual data pasted. This is what Chromium does when files copied in a file manager are pasted: + // it only exposes the files and withholds the text (which on Linux contains their full paths). + // Generate text from the file paths, or from the file names when the paths are not available (e.g. web). const files: File[] = Array.prototype.slice.call(clipboardData.files, 0); - return [files.map(file => file.name).join('\n'), null]; + return [files.map(file => getFilePath(file) || file.name).join('\n'), null]; } return [text, metadata]; }, @@ -301,9 +304,9 @@ export function createClipboardCopyEvent(e: ClipboardEvent, isCut: boolean, cont /** * Creates an IClipboardPasteEvent from a DOM ClipboardEvent. */ -export function createClipboardPasteEvent(e: ClipboardEvent): IClipboardPasteEvent { +export function createClipboardPasteEvent(e: ClipboardEvent, getFilePath: (file: File) => string | undefined = getPathForFile): IClipboardPasteEvent { let handled = false; - let [text, metadata] = e.clipboardData ? ClipboardEventUtils.getTextData(e.clipboardData) : ['', null]; + let [text, metadata] = e.clipboardData ? ClipboardEventUtils.getTextData(e.clipboardData, getFilePath) : ['', null]; metadata = metadata || InMemoryClipboardMetadataManager.INSTANCE.get(text); return { clipboardData: createReadableClipboardData(e.clipboardData), diff --git a/src/vs/editor/test/browser/controller/clipboardUtils.test.ts b/src/vs/editor/test/browser/controller/clipboardUtils.test.ts new file mode 100644 index 00000000000000..630409940eb025 --- /dev/null +++ b/src/vs/editor/test/browser/controller/clipboardUtils.test.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. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { createClipboardPasteEvent } from '../../../browser/controller/editContext/clipboardUtils.js'; + +suite('ClipboardUtils', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('pasting files copied in a file manager generates text from their paths, falling back to their names', () => { + // Chromium only exposes the files in this case and withholds the clipboard text + const files = [ + new File(['hello'], 'example.txt', { type: 'text/plain' }), + new File(['# hi'], 'notes #1.md', { type: 'text/markdown' }), + ]; + const paths = new Map([ + ['example.txt', '/home/john/Downloads/example.txt'], + ['notes #1.md', '/home/john/Downloads/my folder/notes #1.md'], + ]); + + function paste(getFilePath?: (file: File) => string | undefined) { + const clipboardData = new DataTransfer(); + for (const file of files) { + clipboardData.items.add(file); + } + const event = createClipboardPasteEvent(new ClipboardEvent('paste', { clipboardData }), getFilePath); + return { text: event.text, metadata: event.metadata }; + } + + assert.deepStrictEqual({ + filesWithPaths: paste(file => paths.get(file.name)), + filesWithoutPaths: paste(() => undefined), + inMemoryFiles: paste(), + }, { + filesWithPaths: { text: '/home/john/Downloads/example.txt\n/home/john/Downloads/my folder/notes #1.md', metadata: null }, + filesWithoutPaths: { text: 'example.txt\nnotes #1.md', metadata: null }, + inMemoryFiles: { text: 'example.txt\nnotes #1.md', metadata: null }, + }); + }); +}); diff --git a/src/vs/platform/actionWidget/browser/tabbedActionListWidget.ts b/src/vs/platform/actionWidget/browser/tabbedActionListWidget.ts index ba796e9f8bf9a8..ad396a434afd29 100644 --- a/src/vs/platform/actionWidget/browser/tabbedActionListWidget.ts +++ b/src/vs/platform/actionWidget/browser/tabbedActionListWidget.ts @@ -158,8 +158,12 @@ export interface ITabbedActionListShowOptions { * while the popup stays open is not replayed stale on a tab switch. */ readonly widgetClassNames?: (activeTab: string) => readonly string[]; + /** Attaches behavior scoped to the popup's `.action-widget` element for its lifetime. */ + renderWidget?(container: HTMLElement): IDisposable; /** Tab whose contents fix the popup's height, so switching tabs scrolls instead of resizing. */ readonly sizingTab?: string; + /** Remeasures the popup when its content mode changes, without dismissing it or changing its anchor side. */ + readonly sizingKey?: string; /** Show the checked item's hover after the popup has been positioned. */ readonly showCheckedItemHover?: boolean; /** Optional icon buttons rendered after the tabs. */ @@ -235,6 +239,7 @@ export class TabbedActionListWidget extends Disposable { private _fixedListHeight: number | undefined; private _fixedPopupHeight: number | undefined; private _hasMeasuredSizingTab = false; + private _sizingKey: string | undefined; /** Side of the anchor the popup opened on, kept across tab swaps so it cannot jump sides. */ private _anchorPosition: AnchorPosition | undefined; @@ -274,6 +279,12 @@ export class TabbedActionListWidget extends Disposable { this._hasMeasuredSizingTab = false; this._anchorPosition = undefined; } + if (this._sizingKey !== options.sizingKey) { + this._fixedListHeight = undefined; + this._fixedPopupHeight = undefined; + this._hasMeasuredSizingTab = false; + } + this._sizingKey = options.sizingKey; let activeTab = options.initialTab; const popupDisposables = new DisposableStore(); @@ -303,6 +314,9 @@ export class TabbedActionListWidget extends Disposable { let pendingRefresh: ITabbedActionListRefreshOptions | undefined; const widget = dom.append(container, dom.$('.action-widget')); + if (options.renderWidget) { + renderDisposables.add(options.renderWidget(widget)); + } // Programmatic page focus can match :focus-visible even after a pointer click. renderDisposables.add(dom.addDisposableListener(widget, dom.EventType.KEY_DOWN, () => widget.classList.add('keyboard-navigation'), true)); renderDisposables.add(dom.addDisposableListener(widget, dom.EventType.POINTER_DOWN, () => widget.classList.remove('keyboard-navigation'), true)); diff --git a/src/vs/platform/agentHost/AGENTS.md b/src/vs/platform/agentHost/AGENTS.md index b493ca4e023b17..ebd82d0685ea8e 100644 --- a/src/vs/platform/agentHost/AGENTS.md +++ b/src/vs/platform/agentHost/AGENTS.md @@ -333,6 +333,7 @@ Behind the root setting `chatMoves.enabled` (default off). AHP `moveChat` moves - **Config.** A moved chat takes its source's effective `chatMutable` values as its own, written to its database before the commit; a promoted session starts from the source's config with the chat's own values on top. Neither path goes through D1 seeding. A session that moves under another session ends: its chat becomes an ordinary peer there and, like every peer, follows the destination's session-level state (customization enablement, non-`chatMutable` config); only its `chatMutable` values carry. Promotion differs because its destination is a new session, which starts from the source's. - **Data.** A chat's attachments are snapshotted under its recorded storage. A deleted session keeps `attachments/` while a live chat that moved out of it exists, including when that directory is a moved chat's storage; the chat's deletion releases it. After a move, a background job (`AgentHostAttachmentMigration`) copies the files the moved chat's messages attached in its origin (their recorded snapshot URIs under the origin's `attachments/`, never the whole folder) into its own storage under the same relative paths, a few files per step through a temporary file, recording its progress in `agent-host.db` (`metadata` `chatAttachmentCopy:`), so a restart resumes it once the chat's session is restored. A host read (`resourceRead`) of a copied file the origin no longer has, also because an older build deleted it, reads the copy. The origin keeps its attachments while any moved chat lives, also once the copy is complete, because provider-native tools still read the original path; releasing on completion is switched off (`RELEASE_COPIED_ATTACHMENT_ORIGINS`). The copy protects the files from deletions by older builds. A chat moved with its session (its storage is in the origin) copies nothing. - **Worktrees.** A chat keeps the folders it works in, worktrees included (Block 5): the worktree becomes shared by the source and the target (co-owner rows written in the move's transaction), and the source withholds its branch from older builds' copy while it is shared; it is removed only once no live co-owner uses it, and a deletion then commits it. A chat's own additional worktree that no staying chat uses transfers with it; one a staying chat also uses refuses the move (`worktreeFolder`, D2). A session that moves under another one hands its own worktree and additional worktrees to the target (shares them, into a target older builds read no session values of). See 3b, Worktree clean-up. Folder reservations, the git and GitHub rows (`keepInSource`) and Agent Merge's refusal are unchanged. +- **Agent tool.** The `move_session` server tool (`node/shared/sessionMoveTools.ts`, Block 6) lets an agent reorder a nested session, move it into another session, make it its own session, or move a single-chat session under another, through the same `moveChat` / `moveSession` operations and refusals. It is a group of its own, never materialized: offered only while `chatMoves.enabled` is on (read per call; sessions are advertised again when it changes) to an agent with `multipleChats`, so tool lists are unchanged while it is off: `list_sessions` and `get_current_session` mention it in their descriptions only while it is offered (`getDefinitionForSession`). Their results always list a session's main session and nested sessions (`chats`: title, `?chat=` link, `main`, status; list data only), a general improvement whose links also work with `send_message`, `get_session_context` and `rename_chat`. Every call needs confirmation (`sessionConfirmationToolNames`), unless the user turned approvals off (Allow All, Claude `bypassPermissions`, Codex full access), as for `create_session`. Providers that fix their tool list at launch (Claude's allow-list) see the tool after their next launch once the setting turns on. Its check and its move run in the move sequencer like AHP moves, so the sessions they restore never overlap another move. Its confirmation, built by the host (`IAgentServerToolHost.getConfirmationDisplay`), links only sessions and chats the host knows. It resolves sessions only from links and recorded chats, never from a chat URI, and places a chat at the end of its destination by resolving its anchor in the accessor. A move that includes the calling chat is checked when requested and applied when that turn succeeds (`IAgentHostScheduledChatMoves`, contribution order 160); the chat's queued turns are held from that turn end until the move is done, so queue drain (200) starts the next one in the session the chat moved to; a refusal then is only logged. Process-wide backstop: 50 moves. - **Clients.** Clients without `vscode.ahpChatUris` see a chat only in the session it was created in, and nothing that may have moved while the moved-chat origins can't be read. The origins load in the background from startup: such a client's session listing waits for them, any other client's doesn't, and until a chat first moved (a `metadata` marker the move writes) loading them reads that one row instead of scanning `chats_v2`. Clients pair a chat leaving one catalog with the same backend chat appearing in another (`AgentHostChatMoveTracker`) and keep showing it. Mission Control replicates only a session's default chat: promotion starts the new session's SDK stream, and a default chat that moves under another session stops its own. ## 4. Capabilities Gating @@ -581,7 +582,11 @@ Before a message is admitted, a Copilot session can prepare plugins required by Behind the `chat.agentHost.experimental.prewarmSessions` setting, an idle provisional draft can launch its SDK session and start its MCP servers before the first send (`IAgentChats.warmChat`). `AgentHostSessionPrewarmer` (`node/agentHostSessionPrewarmer.ts`) decides when: the provider must support warming, the draft must have an active client and resolved customization enablement, and every launch folder, including additional multi-root folders, must be trusted. A missing trust configuration counts as untrusted; workspace-less drafts skip the trust check because their scratch folder has no workspace content. Ephemeral and imported drafts never warm. The experiment trigger is logged only after these gates, in both arms. -A warm launch is the launch the first send would run, started early and kept on the draft's reservation (`IProvisionalSession.earlyLaunch` in `CopilotAgent`). Its session is registered live, so MCP status and sign-in requests reach it, but it is never persisted, listed or announced until the first send completes it exactly as a cold materialization would. Nothing is applied to it live: when anything fixed at launch has changed by the first send, that send discards it and launches cold. That covers the working directory (always the case for a worktree send), the model or custom agent selection, client tools, plugin or MCP definitions, and the chat's owner. Turn telemetry reports whether the send used it (`usedPrewarmedSession`) or why it discarded it (`prewarmedSessionDiscardReason`). It is also discarded when the chat is disposed, or after 5 unsent minutes. A runtime torn down underneath it (a client restart, closed-connection recovery or idle release) is forgotten while the reservation stays, and its leftover SDK state is deleted before the next launch. An unsent warm launch is never resumed, because the SDK has saved nothing to resume. A worktree draft never warms: its customization enablement stays pending until the worktree is created on the first send. +A dispose of a session waits for a `createSession` of the same session that was requested before it (`AgentService.disposeSession` and `disposeSessionIf`), so it runs after the provider has registered the draft's provisional chat and disposes it. Creation does not offer such a draft to the prewarmer; otherwise its launch would only be discarded by the expiry below. Creation must never dispose its own session through these entry points (it rolls back the provider chat directly), or it would wait on itself. + +A warm launch is the launch the first send would run, started early and kept on the draft's reservation (`IProvisionalSession.earlyLaunch` in `CopilotAgent`). Its session is registered live, so MCP status and sign-in requests reach it, but it is never persisted, listed or announced until the first send completes it exactly as a cold materialization would. Nothing is applied to it live: when anything fixed at launch has changed by the first send, that send discards it and launches cold. That covers the working directory (always the case for a worktree send), the model or custom agent selection, client tools, plugin or MCP definitions, and the chat's owner. Turn telemetry reports whether the send used it (`usedPrewarmedSession`) or why it discarded it (`prewarmedSessionDiscardReason`). It is also discarded when the chat is disposed, or after 5 unsent minutes. A runtime torn down underneath it (a client restart, closed-connection recovery or idle release) is forgotten while the reservation stays, and its leftover SDK state is deleted before the next launch. An unsent warm launch is never resumed, because the SDK has saved nothing to resume. A worktree draft never warms: its customization enablement stays pending until the worktree is created on the first send. While the setting is on, the Agents window therefore keeps the isolation choice local until Send: it creates every draft in the picked folder, even when the user picked or inherited a worktree, and its first request sends the picked isolation (and base branch) with the rest of the draft's config as a `SessionConfigChanged`, which marks a worktree pending while the session is still creating. That folder draft warms; a worktree send discards the warm launch and starts cold in the new worktree, and a folder send claims it. + +Both windows create their draft with the model selected in the chat input (`AgentHostUntitledProvisionalSessionService` in the editor window), because the first send selects its model before it runs and discards a launch made with another one (`modelChanged`). In the editor window, a draft whose chat model was disposed is retired, so chat input chips that still show it while the widget switches to the next chat cannot recreate a backend for it. ### Codex (`node/codex/codexAgent.ts`) diff --git a/src/vs/platform/agentHost/PERFORMANCE.md b/src/vs/platform/agentHost/PERFORMANCE.md index 3b8e1583bf7249..a2d97fd5a604dd 100644 --- a/src/vs/platform/agentHost/PERFORMANCE.md +++ b/src/vs/platform/agentHost/PERFORMANCE.md @@ -284,6 +284,21 @@ Outcomes are `success`, `error`, `unavailable`, `deferred`, `partial`, and `canc The implementation is in [`node/agentHostStartupPerformance.ts`](node/agentHostStartupPerformance.ts). Bootstrap records named milestones and publishes them only after composition has applied the host telemetry configuration. Providers report at their existing enumeration and processed-result boundaries; the orchestrator reports at its existing listing, migration and discovery registration boundaries. Startup telemetry does not use chat lifecycle contributions, since no turn or hydration hook owns these operations. +## Copilot account discovery + +`agentHost.copilotAccountDiscovery` reports one completed `/copilot_internal/user` HTTP lookup from `CopilotApiService`, not cache reads or individual model calls. It uses the existing usage-telemetry transport and consent controls. Local endpoint overrides that skip discovery do not emit it. + +| Field | Meaning | +| --- | --- | +| `trigger` | `initial`, `cacheRefresh`, or `entitlementChange`. | +| `outcome` | `published` when the result updated the discovery cache; `superseded` when a newer revalidation or invalidation prevented publication; `failed` for transport, HTTP, or response-processing failure. | +| `copilotSku`, `copilotTrackingId` | SKU and analytics ID returned by this lookup, when available. These are not copied from the client's entitlement hint or a process-wide common property. | +| `previousSku`, `cacheAgeMs` | Prior cached SKU and its age when lookup was requested. Age is derived from the cache entry's creation/expiry time, not a model request's entitlement age. Both can be absent on initial discovery. | +| `httpStatus`, `gitHubRequestId` | HTTP status and validated `x-github-request-id` header, when a response supplied them. The request ID belongs to discovery, not model inference. | +| `durationMs` | Time from starting HTTP discovery through completion/failure, including response-body consumption. | + +Superseded successful responses retain their returned metadata in this event even though they did not update the cache. Coalesced callers share a single lookup/event; failed attempts do not include response bodies, error messages, credentials, login names, or endpoint URLs. Events are not emitted after service disposal, and transport/consent gaps can leave missing observations. No discovery snapshot ID or additional fields on turn/tool/SDK response events are introduced. Account/time comparisons can reveal inconsistent responses but cannot prove which discovery snapshot or backend authorization decision served an individual model request. + ## Canvas rollout and load telemetry Canvas product telemetry uses existing usage consent, independently of OTel. diff --git a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts index f52de69ec60dd9..942faf752842ee 100644 --- a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts @@ -47,6 +47,8 @@ import type { ClientNotificationMap, CommandMap, JsonRpcErrorResponse, JsonRpcRe import { ActionType, type ActionEnvelope, type ChatAction, type ClientAnnotationsAction, type ClientAutomationAction, type ClientAutomationRunAction, type ClientChangesetAction, type INotification, type IRootConfigChangedAction, type SessionAction, type TerminalAction } from '../common/state/sessionActions.js'; import { getOlderHostDefaultChat } from './agentHostOlderHostCompatibility.js'; import { MessageAttachmentKind, SessionSummary, ROOT_STATE_URI, StateComponents, isAhpRootChannel, isAhpChatChannel, isSessionChatArchived, isSessionStatusRead, type ClientPluginCustomization, type SessionState, type Message, type RootState } from '../common/state/sessionState.js'; +import type { AgentChatMoveDestination } from '../common/agentHostChatMove.js'; +import { ChatMoveDestinationKind } from '../common/state/protocol/channels-chat/commands.js'; import { normalizeLegacyActionEnvelope } from '../common/state/legacyProtocolCompatibility.js'; import { getAgentHostSupportedProtocolVersions } from '../common/agentHostProtocolCompatibility.js'; import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, ProtocolError, ReconnectResultType, JSON_RPC_INTERNAL_ERROR, type ProtocolMessage, type IStateSnapshot, type SubscribeResult } from '../common/state/sessionProtocol.js'; @@ -1419,6 +1421,9 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect private _updateWorkspaceTrust(): void { const remoteAuthority = typeof this._resourceIdentity === 'string' && this._resourceIdentity.startsWith(`${Schemas.vscodeRemote}://`) ? URI.parse(this._resourceIdentity).authority : undefined; + const connectionAuthorityTrusted = this._resourceIdentity !== LOCAL_AGENT_HOST_RESOURCE_IDENTITY + && ((remoteAuthority !== undefined && this._workspaceTrustManagementService.isAuthorityTrusted?.(Schemas.vscodeRemote, remoteAuthority) === true) + || this._workspaceTrustManagementService.isAuthorityTrusted?.(AGENT_HOST_SCHEME, this._connectionAuthority) === true); const trustedUris = this._workspaceTrustManagementService.getTrustedUris() .filter(uri => this._resourceIdentity === LOCAL_AGENT_HOST_RESOURCE_IDENTITY ? uri.scheme === Schemas.file @@ -1431,6 +1436,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect [AgentHostWorkspaceTrustConfigKey]: { enabled: this._workspaceTrustEnablementService.isWorkspaceTrustEnabled(), trustedUris, + ...(connectionAuthorityTrusted ? { connectionAuthorityTrusted: true } : {}), }, }); } @@ -1806,6 +1812,16 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect return URI.parse(result.session); } + async moveChat(chat: URI, destination: AgentChatMoveDestination): Promise { + const result = await this._sendRequest('moveChat', { + channel: chat.toString(), + destination: destination.kind === 'session' + ? { kind: ChatMoveDestinationKind.Session, session: destination.session.toString(), ...(destination.after ? { after: destination.after.toString() } : {}) } + : { kind: ChatMoveDestinationKind.NewSession }, + }); + return URI.parse(result.session); + } + async createDetachedWorktree(session: URI, prompt: string): Promise<{ handle: string; worktree: URI }> { const result = await this._sendExtensionRequest(CreateAgentHostDetachedWorktreeExtensionMethod, { session: session.toString(), diff --git a/src/vs/platform/agentHost/browser/nullAgentHostService.ts b/src/vs/platform/agentHost/browser/nullAgentHostService.ts index 56ac00cb39bce8..f5d27c5c4c9fe1 100644 --- a/src/vs/platform/agentHost/browser/nullAgentHostService.ts +++ b/src/vs/platform/agentHost/browser/nullAgentHostService.ts @@ -10,6 +10,7 @@ import { constObservable, IObservable } from '../../../base/common/observable.js import { URI } from '../../../base/common/uri.js'; import type { IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult } from '../common/agent.js'; import type { AgentHostDebugLogsArtifactKind, IAgentHostDebugLogsArtifact, IAgentHostDebugLogsChunk, IAgentHostInspectInfo, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, IAgentHostService, IAgentHostSocketInfo } from '../common/agentService.js'; +import type { AgentChatMoveDestination } from '../common/agentHostChatMove.js'; import type { IActiveSubscriptionInfo, IAgentSubscription } from '../common/state/agentSubscription.js'; import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; import type { InitializeResult } from '../common/state/protocol/common/commands.js'; @@ -68,6 +69,7 @@ export class NullAgentHostService implements IAgentHostService { async stopBackgroundWork(_chat: URI, _id: string): Promise { return notSupported(); } async importSession(_session: URI): Promise { return notSupported(); } async moveSession(_session: URI, _destination: URI, _after: URI | undefined): Promise { throw new AgentHostSessionMoveUnsupportedError(); } + async moveChat(_chat: URI, _destination: AgentChatMoveDestination): Promise { return notSupported(); } async createDetachedWorktree(_session: URI, _prompt: string): Promise<{ handle: string; worktree: URI }> { return notSupported(); } async claimDetachedWorktree(_handle: string): Promise { return notSupported(); } async setDetachedWorktreeArchived(_handle: string, _archived: boolean): Promise { return notSupported(); } diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts index d864cd35c545b0..ce98fd4f33604b 100644 --- a/src/vs/platform/agentHost/common/agent.ts +++ b/src/vs/platform/agentHost/common/agent.ts @@ -377,6 +377,7 @@ export interface IAgentCustomizationMarketplaceSearchItem { readonly version?: string; readonly repository?: string; readonly path?: string; + readonly connectorIds?: readonly string[]; readonly installable: boolean; readonly unavailableMessage?: string; } @@ -1694,6 +1695,9 @@ export interface IAgent { /** Streamed progress for an exact chat. */ readonly onDidChatProgress: Event; + /** Native snapshots can arrive after host cancellation or steering has already ended a turn. */ + readonly onDidStoreTurnEdits?: Event<{ readonly chat: URI; readonly turnIds: readonly string[] }>; + /** Host-wide progress for provider setup, including work without a chat yet. */ readonly onDidReportProgress?: Event>; @@ -1737,6 +1741,9 @@ export interface IAgent { /** Optional history mutation for providers with a native truncation operation. */ truncateChat?(chat: URI, turnId: string | undefined, context?: URI | IAgentChatContext): Promise; + /** Waits for native file snapshots to be persisted, including after a host turn is cancelled or steered. */ + waitForTurnEdits?(chat: URI, turnId: string): Promise; + /** * Stops an entry of the chat's background work that this provider marked * stoppable. Resolves false when the entry is unknown or had already finished. @@ -1877,8 +1884,8 @@ export interface IAgent { getProtectedResources(): ProtectedResourceMetadata[]; - /** An empty token revokes the credential previously forwarded for this resource. */ - authenticate(resource: string, token: string, expiresIn?: number): Promise; + /** An empty token revokes the credential previously forwarded for this resource. Optional metadata carries client hints, not authorization. */ + authenticate(resource: string, token: string, expiresIn?: number, _meta?: Record): Promise; /** Optional token consumer for provider-owned resources such as MCP servers. */ handleAuthenticationToken?(params: AuthenticateParams): Promise; diff --git a/src/vs/platform/agentHost/common/agentHostChatMove.ts b/src/vs/platform/agentHost/common/agentHostChatMove.ts index edb1135c4863cd..5cdf27b15f4c31 100644 --- a/src/vs/platform/agentHost/common/agentHostChatMove.ts +++ b/src/vs/platform/agentHost/common/agentHostChatMove.ts @@ -13,9 +13,11 @@ export const ChatMoveConfigKey = { Enabled: 'chatMoves.enabled', } as const; -/** Workbench settings mirrored into {@link ChatMoveConfigKey}. */ +/** Workbench settings for moving chats. `Enabled` is mirrored into {@link ChatMoveConfigKey}. */ export const ChatMoveSettingId = { Enabled: 'chat.agentHost.moveChats.enabled', + /** Whether moving a chat or session by drag and drop asks for confirmation; a workbench preference only. */ + ConfirmDragAndDrop: 'chat.agentHost.moveChats.confirmDragAndDrop', } as const; export const chatMoveRootConfigSchema = createSchema({ diff --git a/src/vs/platform/agentHost/common/agentHostEnablementService.ts b/src/vs/platform/agentHost/common/agentHostEnablementService.ts index f769ebb7682eb8..b4f47eca548730 100644 --- a/src/vs/platform/agentHost/common/agentHostEnablementService.ts +++ b/src/vs/platform/agentHost/common/agentHostEnablementService.ts @@ -32,7 +32,8 @@ export interface IAgentHostEnablementService { /** * Whether an enterprise has mandated the Copilot SDK sandbox floor through managed settings * (`sandbox.enabled`). The runtime owns composing and enforcing that floor; VS Code reads it - * to display sandbox state, not to determine harness eligibility. + * to display sandbox state. Like non-empty managed permission deny rules, this floor requires + * Copilot Agent Host for new requests; existing Local history remains readable. * A user- or workspace-level sandbox opt-in does not set this. */ readonly managedSandboxEnforced: IObservable; @@ -47,7 +48,7 @@ configurationRegistry.registerConfiguration({ properties: { [TemporaryDefaultToLocalHarnessSettingId]: { type: 'boolean', - description: nls.localize('chat.temporaryDefaultToLocalHarness', "Temporarily keeps Local available and disables automatic promotion to the Copilot harness. Explicit and remembered harness choices are preserved. Local does not enforce runtime-only sandbox and permission settings."), + description: nls.localize('chat.temporaryDefaultToLocalHarness', "Temporarily keeps Local available and disables automatic promotion to the Copilot harness, including routing required by managed sandbox and permission settings. Explicit and remembered harness choices are preserved. Local does not enforce runtime-only sandbox and permission settings."), default: false, included: false, scope: ConfigurationScope.APPLICATION, @@ -62,7 +63,7 @@ configurationRegistry.registerConfiguration({ localization: { description: { key: 'chat.temporaryDefaultToLocalHarness.policy', - value: nls.localize('chat.temporaryDefaultToLocalHarness.policy', "Temporarily keeps Local available and prevents automatic selection of the Copilot harness, overriding rollout settings. Local does not enforce runtime-only sandbox and permission settings."), + value: nls.localize('chat.temporaryDefaultToLocalHarness.policy', "Temporarily keeps Local available and prevents automatic selection of the Copilot harness, overriding rollout settings and managed sandbox and permission routing. Local does not enforce runtime-only sandbox and permission settings."), }, }, }, diff --git a/src/vs/platform/agentHost/common/agentHostSchema.ts b/src/vs/platform/agentHost/common/agentHostSchema.ts index 49010c6363d0b6..44f9a573ff9eb9 100644 --- a/src/vs/platform/agentHost/common/agentHostSchema.ts +++ b/src/vs/platform/agentHost/common/agentHostSchema.ts @@ -553,9 +553,15 @@ export const AgentHostAutoReplyAnswer = 'The user is not available to answer you export const AgentHostWorkspaceTrustConfigKey = 'workspaceTrust'; -interface IAgentHostWorkspaceTrust { +export interface IAgentHostWorkspaceTrust { readonly enabled: boolean; readonly trustedUris: readonly string[]; + /** Whether the current Agent Host connection authority is explicitly trusted. */ + readonly connectionAuthorityTrusted?: boolean; +} + +export function areAllAgentHostWorkspaceResourcesTrusted(trust: IAgentHostWorkspaceTrust): boolean { + return !trust.enabled || trust.connectionAuthorityTrusted === true; } /** Root config key forwarded from the renderer for automatic OS system proxy discovery. */ @@ -950,6 +956,7 @@ export const platformRootSchema = createSchema({ title: localize('agentHost.config.workspaceTrust.trustedUris', "Trusted Folders"), items: { type: 'string', title: localize('agentHost.config.workspaceTrust.uri', "Folder URI") }, }, + connectionAuthorityTrusted: { type: 'boolean', title: localize('agentHost.config.workspaceTrust.connectionAuthorityTrusted', "Connection Authority Trusted") }, }, required: ['enabled', 'trustedUris'], readOnly: true, diff --git a/src/vs/platform/agentHost/common/agentHostSessionStateStore.ts b/src/vs/platform/agentHost/common/agentHostSessionStateStore.ts index dd595ed3a59fd9..0cb3262832bfac 100644 --- a/src/vs/platform/agentHost/common/agentHostSessionStateStore.ts +++ b/src/vs/platform/agentHost/common/agentHostSessionStateStore.ts @@ -345,8 +345,8 @@ export interface ISessionStateKnownCatalog { export interface IAgentHostChatRowStore { getDraft(session: URI, chat: URI): Promise; setDraft(session: URI, chat: URI, draft: Message | undefined): Promise; - /** The persisted turns of `chat`, ordered by `seq`. */ - getPersistedTurns(session: URI, chat: URI): Promise>; + /** The persisted turns of `chat`, ordered by `seq`, resolving local anchors to IDs in the restored history when supplied. */ + getPersistedTurns(session: URI, chat: URI, visibleTurnIds?: ReadonlySet): Promise>; /** Persists `record` for `record.chatUri`. */ insertPersistedTurn(session: URI, record: IPersistedTurnRecord): Promise; deletePersistedTurns(session: URI, chat: URI, turnIds: readonly string[]): Promise; diff --git a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts index 301ae4bea56b93..e8359020f4c03b 100644 --- a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts +++ b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts @@ -223,6 +223,13 @@ configurationRegistry.registerConfiguration({ tags: ['experimental'], agentHost: { key: ChatMoveConfigKey.Enabled }, }, + [ChatMoveSettingId.ConfirmDragAndDrop]: { + type: 'boolean', + description: nls.localize('chat.agentHost.moveChats.confirmDragAndDrop', "Controls whether the Agents window asks for confirmation before moving a chat or session to another session or into a new session via drag and drop. Reordering chats within a session is never confirmed."), + default: true, + scope: ConfigurationScope.APPLICATION, + tags: ['experimental'], + }, ...titleGenerationConfigurationProperties, ...artifactToolsConfigurationProperties, [CanvasesEnabledSettingId]: { diff --git a/src/vs/platform/agentHost/common/agentServerTools.ts b/src/vs/platform/agentHost/common/agentServerTools.ts index 52d9122510071f..a97f38edd6f6bf 100644 --- a/src/vs/platform/agentHost/common/agentServerTools.ts +++ b/src/vs/platform/agentHost/common/agentServerTools.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { ToolDefinition, URI } from './state/sessionState.js'; +import type { StringOrMarkdown, ToolDefinition, URI } from './state/sessionState.js'; /** * A server tool definition plus agent-host-local metadata that is not part of @@ -40,6 +40,13 @@ export interface IAgentServerToolDefinition extends ToolDefinition { * Tool invocation methods take the exact Agent Host chat channel URI. The host * resolves its owning session for session-scoped state and tools. */ +/** The confirmation a server tool's call shows, built with the host's state. */ +export interface IServerToolConfirmationDisplay { + readonly confirmationTitle?: string; + readonly confirmationMessage?: StringOrMarkdown; + readonly hideConfirmationInput?: boolean; +} + export interface IAgentServerToolHost { /** Every server tool definition across the contributed groups. */ readonly definitions: readonly IAgentServerToolDefinition[]; @@ -70,6 +77,12 @@ export interface IAgentServerToolHost { * Providers must consult this before prompting or executing the tool. */ requiresConfirmation(chatUri: URI, toolName: string): boolean; + /** + * The confirmation a call of {@link toolName} with {@link args} shows, built by its group with the host's state + * (for example, linking only sessions the host knows). `undefined` for a tool with no confirmation display, where + * providers fall back to their generic one. + */ + getConfirmationDisplay(toolName: string, args: unknown): IServerToolConfirmationDisplay | undefined; /** * Executes a server tool for the exact chat that invoked it, dispatching any * resulting actions, and returns the textual tool result for the agent. diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index e7b8101d581895..95ad061f3e0202 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -1361,6 +1361,12 @@ export interface IAgentConnection { * when the host doesn't advertise `vscode.sessionMove`. */ moveSession(session: URI, destination: URI, after: URI | undefined): Promise; + /** + * Moves `chat` (AHP `moveChat`) within its session, into another session, or into a new session, and resolves to + * its owning session after the move. A host refusal rejects with a `ProtocolError` whose `data.reason` is an + * `AgentChatMoveRefusal`. + */ + moveChat(chat: URI, destination: AgentChatMoveDestination): Promise; /** Requires the VS Code artifact removal capability advertised by initialize. */ removeSessionArtifact?(session: URI, artifactId: string): Promise; /** Only for background work whose `_meta` the host marked stoppable. Resolves false when it had already finished. */ diff --git a/src/vs/platform/agentHost/common/meta/agentEntitlementHintMeta.ts b/src/vs/platform/agentHost/common/meta/agentEntitlementHintMeta.ts new file mode 100644 index 00000000000000..3d787638ca174f --- /dev/null +++ b/src/vs/platform/agentHost/common/meta/agentEntitlementHintMeta.ts @@ -0,0 +1,7 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export { agentEntitlementHintMeta, readAgentEntitlementHint } from './vscode/agentEntitlementHintMeta.js'; +export type { IAgentEntitlementHint } from './vscode/agentEntitlementHintMeta.js'; diff --git a/src/vs/platform/agentHost/common/meta/mcpCustomizationMeta.ts b/src/vs/platform/agentHost/common/meta/mcpCustomizationMeta.ts index 2db68df127f893..e06994a705f4c4 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 { readMcpServerControllingSetting, readMcpServerDisplayName, readMcpServerSource, readMcpServerSourcePlugin, withMcpServerControllingSettingMeta, withMcpServerDisplayNameMeta, withMcpServerSourceMeta, withMcpServerSourcePluginMeta } from './vscode/mcpCustomizationMeta.js'; +export { readMcpServerConnectorName, readMcpServerControllingSetting, readMcpServerDisplayName, readMcpServerSource, readMcpServerSourcePlugin, withMcpServerConnectorNameMeta, withMcpServerControllingSettingMeta, withMcpServerDisplayNameMeta, withMcpServerSourceMeta, withMcpServerSourcePluginMeta } from './vscode/mcpCustomizationMeta.js'; diff --git a/src/vs/platform/agentHost/common/meta/vscode/agentEntitlementHintMeta.ts b/src/vs/platform/agentHost/common/meta/vscode/agentEntitlementHintMeta.ts new file mode 100644 index 00000000000000..84decc7db5e817 --- /dev/null +++ b/src/vs/platform/agentHost/common/meta/vscode/agentEntitlementHintMeta.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 { isObject } from '../../../../../base/common/types.js'; + +const entitlementKey = 'vscode.authentication.entitlement'; + +/** Client-observed account metadata used only to request discovery revalidation. */ +export interface IAgentEntitlementHint { + readonly trackingId: string; + readonly sku: string; +} + +export function readAgentEntitlementHint(source: { readonly _meta?: Record }): IAgentEntitlementHint | undefined { + const value = source._meta?.[entitlementKey]; + if (!isObject(value)) { + return undefined; + } + const raw = value as Record; + if (typeof raw.trackingId !== 'string' || !raw.trackingId.trim() + || typeof raw.sku !== 'string' || !raw.sku.trim()) { + return undefined; + } + return { trackingId: raw.trackingId, sku: raw.sku }; +} + +export function agentEntitlementHintMeta(hint: IAgentEntitlementHint): Record { + return { [entitlementKey]: { ...hint } }; +} diff --git a/src/vs/platform/agentHost/common/meta/vscode/mcpCustomizationMeta.ts b/src/vs/platform/agentHost/common/meta/vscode/mcpCustomizationMeta.ts index ccbdf3e1dcfccb..2f3f347edeab99 100644 --- a/src/vs/platform/agentHost/common/meta/vscode/mcpCustomizationMeta.ts +++ b/src/vs/platform/agentHost/common/meta/vscode/mcpCustomizationMeta.ts @@ -10,6 +10,7 @@ const displayNameKey = 'vscode.mcpServerDisplayName'; const maxDisplayNameLength = 512; const sourcePluginKey = 'agentHost.mcpServerSourcePlugin'; const controllingSettingKey = 'vscode.mcpServerControllingSetting'; +const connectorNameKey = 'vscode.mcpServerConnectorName'; export type McpServerSource = | 'user' // Defined in user-level configuration. @@ -95,6 +96,23 @@ export function withMcpServerControllingSettingMeta(meta: Record | undefined, connectorName: string | undefined): Record | undefined { + return withMetaEntry(meta, connectorNameKey, connectorName); +} + function readNonEmptyString(value: unknown): string | undefined { return typeof value === 'string' && value.trim().length > 0 ? value : undefined; } diff --git a/src/vs/platform/agentHost/common/serverToolNames.ts b/src/vs/platform/agentHost/common/serverToolNames.ts index 69fd7d1a930335..20e00a978aee16 100644 --- a/src/vs/platform/agentHost/common/serverToolNames.ts +++ b/src/vs/platform/agentHost/common/serverToolNames.ts @@ -27,6 +27,7 @@ export const enum SessionServerToolName { SendMessage = 'send_message', GetSessionContext = 'get_session_context', DeleteSession = 'delete_session', + MoveSession = 'move_session', } /** diff --git a/src/vs/platform/agentHost/common/sessionDataService.ts b/src/vs/platform/agentHost/common/sessionDataService.ts index 6c44a380bfa331..ec37ed3c8ab84a 100644 --- a/src/vs/platform/agentHost/common/sessionDataService.ts +++ b/src/vs/platform/agentHost/common/sessionDataService.ts @@ -44,7 +44,7 @@ export function isSessionAttachmentPath(sessionDataService: ISessionDataService, export interface IFileEditRecord { /** The turn that owns this file edit. */ turnId: string; - /** The tool call that produced this edit. */ + /** The tool call or cumulative provider turn snapshot that produced this edit. */ toolCallId: string; /** Primary file path (after-path for edits/creates/renames, before-path for deletes). */ filePath: string; @@ -215,9 +215,9 @@ export interface ISessionDatabase extends IDisposable { deleteTurn(turnId: string): Promise; /** - * Associates a Copilot SDK event ID with a turn. The event ID corresponds - * to the `user.message` event in the SDK event stream and is used by - * the SDK's `history.truncate` and `sessions.fork` RPCs. + * Associates a provider history ID with a host turn: a Copilot user-message + * event ID or a Codex native turn ID. Used to resolve restored history and + * provider truncation and fork boundaries. */ setTurnEventId(turnId: string, eventId: string): Promise; @@ -368,6 +368,9 @@ export interface ISessionDatabase extends IDisposable { */ deleteAllTurns(): Promise; + /** Deletes provider turns and later host segments of a retained native turn, preserving host-persisted and foreign turns. */ + deleteProviderTurns(providerTurnIds: readonly string[], afterHostTurnId?: string): Promise; + // ---- Host-persisted turns -------------------------------------------- /** @@ -392,7 +395,7 @@ export interface ISessionDatabase extends IDisposable { /** * Store a file-edit snapshot (metadata + content) for a tool invocation - * within a turn. + * or cumulative provider turn snapshot. * * If a record for the same `toolCallId` and `filePath` already exists * it is replaced. diff --git a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts index dbac11d1104f34..d15db34a24e242 100644 --- a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts @@ -79,6 +79,7 @@ import { readAgentHostOTelPolicySettings, type IAgentHostDebugLogsChunk, } from '../common/agentService.js'; +import type { AgentChatMoveDestination } from '../common/agentHostChatMove.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'; @@ -523,6 +524,10 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos return this._requireClient().moveSession(session, destination, after); } + moveChat(chat: URI, destination: AgentChatMoveDestination): Promise { + return this._requireClient().moveChat(chat, destination); + } + readSavedChat(session: URI, chat?: URI): Promise { return this._requireClient().readSavedChat(session, chat); } diff --git a/src/vs/platform/agentHost/node/agentHostAuthenticationService.ts b/src/vs/platform/agentHost/node/agentHostAuthenticationService.ts index 1394564d1b2479..5d72da766f5a52 100644 --- a/src/vs/platform/agentHost/node/agentHostAuthenticationService.ts +++ b/src/vs/platform/agentHost/node/agentHostAuthenticationService.ts @@ -154,7 +154,7 @@ export class AgentHostAuthenticationService extends Disposable implements IAgent p => p.getProtectedResources().some(r => r.resource === params.resource), ); const settled = await Promise.allSettled( - matching.map(p => p.authenticate(params.resource, params.token, params.expiresIn)), + matching.map(p => p.authenticate(params.resource, params.token, params.expiresIn, params._meta)), ); let authenticated = false; let rejected = false; diff --git a/src/vs/platform/agentHost/node/agentHostChangesetService.ts b/src/vs/platform/agentHost/node/agentHostChangesetService.ts index 270c92412b6d1c..44dc7cf6be9eaf 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetService.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetService.ts @@ -12,6 +12,7 @@ import { autorun, derived, observableFromEvent } from '../../../base/common/obse import { isObject } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; import { IAgentHostPeerChatPersistenceService } from './agentHostPeerChatStore.js'; +import { IAgentHostProviderService } from './agentHostProviderService.js'; import { localize } from '../../../nls.js'; import { ILogService } from '../../log/common/log.js'; import { @@ -63,7 +64,7 @@ import { dedupeSessionFileDiffs } from './agentHostMultiRootDiff.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { reportAgentHostStaticChangesetComputed, reportAgentHostTurnChangesetComputed, type IMultiRootTurnDiffMetrics, type StaticChangesetOutcome, type TurnChangesetOutcome } from './agentHostChangesetTelemetry.js'; import type { IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; -import { AgentSession } from '../common/agent.js'; +import { AgentSession, type IAgent } from '../common/agent.js'; import { IAgentHostWorktreeIsolation, type IAgentHostWorktreePendingState } from './shared/worktreeIsolation.js'; import { getSummaryChangesetKind, resolveChangesetSubscriptions } from './agentHostChangesetSummary.js'; import { SessionConfigKey } from '../common/sessionConfigKeys.js'; @@ -198,11 +199,26 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC @IAgentHostPeerChatPersistenceService private readonly _chatPersistence: IAgentHostPeerChatPersistenceService, @IAgentHostChatOwnershipService private readonly _chatOwnership: IAgentHostChatOwnershipService, @IAgentHostSessionStateStore private readonly _sessionStateStore: IAgentHostSessionStateStore, + @IAgentHostProviderService private readonly _providerService: IAgentHostProviderService, ) { super(); this._worktree = worktree; this._diffComputeService = this._createDiffComputeService(); this._register(this._changesetSubscriptions.onDidChangeSessionSubscriptions(owner => this._observeUncommittedRoot(owner))); + this._register(this._providerService.onDidRegisterProvider(provider => this._observeProviderTurnEdits(provider))); + for (const provider of this._providerService.getProviders()) { + this._observeProviderTurnEdits(provider); + } + } + + private _observeProviderTurnEdits(provider: IAgent): void { + if (provider.onDidStoreTurnEdits) { + this._register(provider.onDidStoreTurnEdits(({ chat, turnIds }) => { + if (this._chatOwnership.getChatOwner(chat.toString())) { + this._onTrackedEditsChanged(chat.toString(), turnIds); + } + })); + } } /** Creates the diff-count service; overridable so tests can supply a synchronous in-process computer. */ @@ -884,6 +900,10 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC } return { diffs: [], outcome: 'computed' }; } + const pendingEdits = this._waitForTurnEdits(trackedSource.owner, turnId); + if (pendingEdits) { + await pendingEdits; + } if (strategy === 'fileEditTracker') { const folderScope = this._getTrackedEditFolderScope(trackedSource.owner, this._configurationService.getEffectiveWorkingDirectories(trackedSource.owner)); return { diffs: await computeTurnDiffs(trackedSource.storage, trackedSource.db, this._diffComputeService, turnId, folderScope), outcome: 'computed' }; @@ -894,6 +914,13 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC } } + /** Returns a native persistence barrier without introducing a wait for providers that do not need one. */ + private _waitForTurnEdits(owner: ProtocolURI, turnId: string): Promise | undefined { + const session = this._containingSessionUri(owner); + const chat = this._chatOwnership.getChatOwner(owner) !== undefined ? owner : this._chatOwnership.getDefaultChat(session); + return chat ? this._providerService.getProviderForSession(session)?.waitForTurnEdits?.(URI.parse(chat), turnId) : undefined; + } + private async _computeTurnDiffsUsingGit(session: ProtocolURI, turnId: string, source: GitTurnDiffSource): Promise { const workingDirectories = this._configurationService.getEffectiveWorkingDirectories(session); if (isMultiRootSession(workingDirectories)) { @@ -1497,8 +1524,11 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC } onFileEditsDiscarded(owner: ProtocolURI, turnIds: readonly string[]): void { - // The discarded edits change past results, so recompute from scratch - // (no changedTurnId) rather than incrementally. + this._onTrackedEditsChanged(owner, turnIds); + } + + private _onTrackedEditsChanged(owner: ProtocolURI, turnIds: readonly string[]): void { + // Snapshots arriving late and discarded edits both invalidate prior results: recompute from scratch. for (const turnId of turnIds) { this._cancelDebouncedTurnDiffComputation(owner, turnId); if (this._hasSubscription(owner, buildTurnChangesetUri(owner, turnId))) { diff --git a/src/vs/platform/agentHost/node/agentHostChatRowStore.ts b/src/vs/platform/agentHost/node/agentHostChatRowStore.ts index a7fa3bcd601bfd..ce4da9e3c989b1 100644 --- a/src/vs/platform/agentHost/node/agentHostChatRowStore.ts +++ b/src/vs/platform/agentHost/node/agentHostChatRowStore.ts @@ -98,8 +98,20 @@ export class AgentHostChatRowStore implements IAgentHostChatRowStore { return this._read(session, chat, db => db.getChatDraft(chat), undefined); } - async getPersistedTurns(session: URI, chat: URI): Promise> { - return this._read(session, chat, db => db.getPersistedTurnsForChat(chat.toString()), []); + async getPersistedTurns(session: URI, chat: URI, visibleTurnIds?: ReadonlySet): Promise> { + return this._read(session, chat, async db => { + const records = await db.getPersistedTurnsForChat(chat.toString()); + if (!visibleTurnIds) { + return records; + } + return Promise.all(records.map(async record => { + if (record.kind !== 'local' || record.anchorTurnId === undefined || visibleTurnIds.has(record.anchorTurnId)) { + return record; + } + const eventId = await db.getTurnEventId(record.anchorTurnId); + return eventId !== undefined && visibleTurnIds.has(eventId) ? { ...record, anchorTurnId: eventId } : record; + })); + }, []); } setDraft(session: URI, chat: URI, draft: Message | undefined): Promise { diff --git a/src/vs/platform/agentHost/node/agentHostLocalTurns.ts b/src/vs/platform/agentHost/node/agentHostLocalTurns.ts index 9812f223023b37..b00e487bbbb68b 100644 --- a/src/vs/platform/agentHost/node/agentHostLocalTurns.ts +++ b/src/vs/platform/agentHost/node/agentHostLocalTurns.ts @@ -112,8 +112,8 @@ export class AgentHostLocalTurns implements IAgentHostLocalTurns { * `seq` order so the caller can interleave them into that chat's SDK-derived * turns during restore. */ - async loadForChat(session: string, chat: string): Promise { - return this._load(session, chat); + async loadForChat(session: string, chat: string, visibleTurnIds?: ReadonlySet): Promise { + return this._load(session, chat, visibleTurnIds); } /** Note a local turn in memory only (used by fork seeding). */ @@ -140,9 +140,9 @@ export class AgentHostLocalTurns implements IAgentHostLocalTurns { this._byChat.delete(chat); } - private async _load(session: string, chat: string): Promise { + private async _load(session: string, chat: string, visibleTurnIds?: ReadonlySet): Promise { try { - const persisted = await this._chatRows.getPersistedTurns(URI.parse(session), URI.parse(chat)); + const persisted = await this._chatRows.getPersistedTurns(URI.parse(session), URI.parse(chat), visibleTurnIds); for (const record of persisted) { this._seqBySession.set(session, Math.max(this._seqBySession.get(session) ?? 0, record.seq)); if (record.kind === 'local') { diff --git a/src/vs/platform/agentHost/node/agentHostSessionPrewarmer.ts b/src/vs/platform/agentHost/node/agentHostSessionPrewarmer.ts index b70026bbcbc6c8..decf4b970349a9 100644 --- a/src/vs/platform/agentHost/node/agentHostSessionPrewarmer.ts +++ b/src/vs/platform/agentHost/node/agentHostSessionPrewarmer.ts @@ -12,7 +12,7 @@ import { URI } from '../../../base/common/uri.js'; import { ILogService } from '../../log/common/log.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { type IAgent, type IAgentChatContext } from '../common/agent.js'; -import { AgentHostPrewarmSessionsConfigKey, AgentHostWorkspaceTrustConfigKey, platformRootSchema } from '../common/agentHostSchema.js'; +import { AgentHostPrewarmSessionsConfigKey, AgentHostWorkspaceTrustConfigKey, areAllAgentHostWorkspaceResourcesTrusted, platformRootSchema } from '../common/agentHostSchema.js'; import { AgentHostPrewarmSessionsSettingId } from '../common/agentService.js'; import { ActionType } from '../common/state/sessionActions.js'; import { IAgentConfigurationService } from './agentConfigurationService.js'; @@ -69,9 +69,10 @@ export interface IAgentHostPrewarmCandidate { * A worktree draft never warms: its enablement stays pending until its * worktree is created on the first send, so it fails gate 3. A client that * keeps the isolation choice local until Send (the Agents window) never - * creates such a draft: its draft warms as a folder draft, and a worktree - * send discards that warm launch and starts cold in the worktree (see - * `CopilotAgent._takeEarlyLaunch`). + * creates such a draft: it creates the draft in the picked folder even when + * the user picked a worktree, so the draft warms as a folder draft, and a + * worktree send discards that warm launch and starts cold in the worktree + * (see `CopilotAgent._takeEarlyLaunch`). */ export class AgentHostSessionPrewarmer extends Disposable { @@ -162,7 +163,7 @@ export class AgentHostSessionPrewarmer extends Disposable { if (!trust) { return false; } - if (!trust.enabled) { + if (areAllAgentHostWorkspaceResourcesTrusted(trust)) { return true; } return trust.trustedUris.some(uri => { diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index f3786c6cac1868..ddc3236423fee2 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -50,7 +50,7 @@ import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } f import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, JsonRpcErrorCodes, ProtocolError, ResourceChangeType, type CreateResourceWatchParams, type CreateResourceWatchResult, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMkdirParams, type ResourceMkdirResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceResolveParams, type ResourceResolveResult, type ResourceWatchState, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { ChangesSummary, ChatInteractivity, ChatOriginKind, MessageAttachmentKind, PendingMessageKind, TerminalClaimKind, TerminalLifecycleStatus, type Annotation, type AnnotationEntry, type AnnotationOrigin, type AnnotationsState, type ChatOrigin, type ChatState, type Customization, type Message, type MessageAttachment, type MessageResourceAttachment, type TerminalState, type TextRange, type ToolResultTerminalContent } from '../common/state/protocol/state.js'; import type { ChatConfigChangedAction, ChatIsArchivedChangedAction, ChatIsReadChangedAction, ChatPendingMessageSetAction, ChatTurnStartedAction, SessionConfigChangedAction } from '../common/state/protocol/actions.js'; -import { isAhpAutomationCatalogChannel, isAhpAutomationRunChannel, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_AUTO_ARCHIVED_AT_DB_KEY, AH_META_CREATED_BY_SESSION_DB_KEY, readSessionCreationReference, readSessionComparisonMetadata, readSessionSpawnDepth, withSessionSpawnDepth, withSessionCreationReference, parseSessionCreationReference, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, TurnState, AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY, AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_DEFAULT_CHAT_IS_READ_DB_KEY, buildResourceWatchChannelUri, buildSubagentSessionUriPrefix, getErrorResponsePart, getSessionChatResource, isAhpChatChannel, isChatInSessionReadAggregate, isChatReadOnly, isSessionChatArchived, isSessionStatusArchived, isSessionStatusRead, isSubagentSession, needsSessionGitStateRefresh, parseResourceWatchChannelUri, parseSessionGitData, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withSessionExternal, withSessionGitData, withSessionGitHubState, withSessionGitState, withSessionHasWorkspaceTransitions, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionEhcliAdopted, withSessionEhcliLastMigratedTurn, AH_META_EHCLI_LAST_TURN_DB_KEY, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, getAllSessionRelatedPullRequestUrls, readSessionEhcliAdoptable, readSessionEhcliAdopted, readSessionGitHubData, parseSessionGitHubData, parseSessionGitHubState, readSessionGitHubStateInput, withMigratedSessionGitHubState, withReplacedFolderGitHubState, withMostRecentRelatedSessionPullRequest, SESSION_META_GITHUB_DATA_KEY, withWorkingDirectoryKey, withWorkingDirectoryScopeId, type ISessionGitHubState, type ISessionSourceControlState, type ChatSummary, type ISessionWithDefaultChat, type ProjectInfo, type SessionConfigState, type SessionSummary, type SessionSummaryMeta, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; +import { getAgentHostChatId, isAhpAutomationCatalogChannel, isAhpAutomationRunChannel, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_AUTO_ARCHIVED_AT_DB_KEY, AH_META_CREATED_BY_SESSION_DB_KEY, readSessionCreationReference, readSessionComparisonMetadata, readSessionSpawnDepth, withSessionSpawnDepth, withSessionCreationReference, parseSessionCreationReference, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, TurnState, AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY, AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_DEFAULT_CHAT_IS_READ_DB_KEY, buildResourceWatchChannelUri, buildSubagentSessionUriPrefix, getErrorResponsePart, getSessionChatResource, isAhpChatChannel, isChatInSessionReadAggregate, isChatReadOnly, isSessionChatArchived, isSessionStatusArchived, isSessionStatusRead, isSubagentSession, needsSessionGitStateRefresh, parseResourceWatchChannelUri, parseSessionGitData, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withSessionExternal, withSessionGitData, withSessionGitHubState, withSessionGitState, withSessionHasWorkspaceTransitions, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionEhcliAdopted, withSessionEhcliLastMigratedTurn, AH_META_EHCLI_LAST_TURN_DB_KEY, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, getAllSessionRelatedPullRequestUrls, readSessionEhcliAdoptable, readSessionEhcliAdopted, readSessionGitHubData, parseSessionGitHubData, parseSessionGitHubState, readSessionGitHubStateInput, withMigratedSessionGitHubState, withReplacedFolderGitHubState, withMostRecentRelatedSessionPullRequest, SESSION_META_GITHUB_DATA_KEY, withWorkingDirectoryKey, withWorkingDirectoryScopeId, type ISessionGitHubState, type ISessionSourceControlState, type ChatSummary, type ISessionWithDefaultChat, type ProjectInfo, type SessionConfigState, type SessionSummary, type SessionSummaryMeta, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; import { readToolCallMeta } from '../common/meta/agentToolCallMeta.js'; import { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../common/meta/agentSnapshotAttachmentMeta.js'; import { readEphemeralSessionMeta, withEphemeralSessionMeta } from '../common/meta/agentEphemeralSessionMeta.js'; @@ -103,7 +103,7 @@ import { SessionDbResourceProvider } from './resourceProviders/sessionDbResource import { ToolResultResourceProvider } from './resourceProviders/toolResultResourceProvider.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 { type IAddSessionWorkingDirectoryOptions, type IAgentServiceSessionServerToolAccessor, type IPreparedChatWorkingDirectory, type IChatContextSnapshot, type IRenameTitleResult, type ISessionCreationDefaults, type ISessionServerToolMove, type SessionServerToolMoveAnchor, validateRenameTitle } from './shared/sessionServerTools.js'; import { AGENT_HOST_TITLE_SOURCE_AGENT, AGENT_HOST_TITLE_SOURCE_AUTO, AGENT_HOST_TITLE_SOURCE_USER, 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'; @@ -186,6 +186,25 @@ interface IPeerChatDeletionStorage { readonly movedFrom: readonly string[]; } +/** A move request {@link AgentService} accepted before preparing any catalog, with what its kind needs. */ +type IChatMoveRequest = + | { readonly kind: 'reorder' | 'move'; readonly destination: Extract } + | { readonly kind: 'promote'; readonly provider: IAgent }; + +/** What a chat move validates against and plans its worktrees by (see `_readChatMoveInputs`). */ +interface IChatMoveInputs { + /** The chat and its host-managed descendants. */ + readonly moved: readonly string[]; + /** The chat's entry in its source's catalog. */ + readonly sourceSummary: ChatSummary | undefined; + /** The folders the chat works in. */ + readonly chatFolders: readonly string[]; + /** The folders the chat and its moved descendants work in. */ + readonly subtreeFolders: readonly string[]; + readonly sourceWorktrees: ISessionWorktreeRecords; + readonly targetPrimaryWorktree: string | undefined; +} + /** How often a move stages and converts a new session's chat catalog while concurrent writes dirty it (see `_normalizeCatalogForMove`). */ const CHAT_MOVE_CONVERSION_ATTEMPTS = 3; /** Deferrals in a row, waiting for a session's re-projection, after which a chat catalog conversion is reported at warn. */ @@ -1878,6 +1897,21 @@ export class AgentService extends Disposable implements IAgentService { type: ActionType.SessionMetaChanged, _meta: withSessionSpawnDepth(this._stateManager.getSessionSummary(session.toString())?._meta, depth), }), + isChatMoveEnabled: () => this._configurationService.getRootValue(chatMoveRootConfigSchema, ChatMoveConfigKey.Enabled) === true, + supportsChatMoves: session => !!this._providerService.getProviderForSession(session)?.getDescriptor().capabilities?.multipleChats, + // Queued like the moves themselves: the check restores sessions, which must not overlap a running move. + checkChatMove: move => this._logRefusedServerToolMove(this._chatMoveSequencer.queue(() => this._checkServerToolMove(move))), + applyChatMove: move => this._logRefusedServerToolMove(this._applyServerToolMove(move)), + isKnownSessionLink: (session, chatId) => { + const key = session.toString(); + const state = this._stateManager.getSessionState(key); + const summary = state ? undefined : this._stateManager.getSessionSummary(key) ?? this._stateManager.getSurfacedSessionSummary(key); + if (!state && !summary) { + return false; + } + // A loaded or listed session is known; a chat of it only among its recorded chats, which a single-chat summary may not list. + return chatId === undefined || (state?.chats ?? summary?.chats ?? []).some(chat => chat.resource === chatId || getAgentHostChatId(chat.resource) === chatId); + }, }; } @@ -5952,7 +5986,9 @@ export class AgentService extends Disposable implements IAgentService { // Only an idle draft can run its provider before the first send. An // ephemeral one is short-lived by design and launches with MCP disabled, // so warming it would start nothing worth having. - if (isIdleProvisional && !isEphemeral) { + // A dispose that arrived during creation is waiting for it (see + // `disposeSession`); warming that draft would outlive it. + if (isIdleProvisional && !isEphemeral && !this._sessionResidency.isBeingDisposed(session)) { this._sessionPrewarmer.onProvisionalSessionCreated({ provider, session, defaultChat, workingDirectories: config?.workingDirectories }); } return session; @@ -6693,17 +6729,22 @@ export class AgentService extends Disposable implements IAgentService { } moveSession(session: URI, destination: URI, after: URI | undefined): Promise { - return this._chatMoveSequencer.queue(async () => { - const sourceKey = session.toString(); - this._assertChatMovesEnabled(); - // The host knows a session's default chat only once it has loaded the session. - await this._restoreForMove(session, AgentChatMoveRefusal.NotMovable); - const defaultChat = this._stateManager.getSessionState(sourceKey)?.defaultChat ?? this._defaultChat(sourceKey); - if (defaultChat === undefined) { - throw new AgentChatMoveRefusedError(AgentChatMoveRefusal.NotMovable, `Session ${sourceKey} is not available.`); - } - return this._moveChat(session, URI.parse(defaultChat), { kind: 'session', session: destination, after }, true); - }); + return this._chatMoveSequencer.queue(async () => this._moveChat(session, await this._movedSessionChat(session), { kind: 'session', session: destination, after }, true)); + } + + /** + * The chat a session that moves under another session takes along: its default chat, which must be its only one. + * The host knows a session's default chat only once it has loaded the session, so it restores one it hasn't. + */ + private async _movedSessionChat(session: URI): Promise { + const sourceKey = session.toString(); + this._assertChatMovesEnabled(); + await this._restoreForMove(session, AgentChatMoveRefusal.NotMovable); + const defaultChat = this._stateManager.getSessionState(sourceKey)?.defaultChat ?? this._defaultChat(sourceKey); + if (defaultChat === undefined) { + throw new AgentChatMoveRefusedError(AgentChatMoveRefusal.NotMovable, `Session ${sourceKey} is not available.`); + } + return URI.parse(defaultChat); } private _assertChatMovesEnabled(): void { @@ -6725,57 +6766,202 @@ export class AgentService extends Disposable implements IAgentService { } /** - * Moves `chat` and its descendants out of `session`. With `sessionMove` (Block 4, VS Code-internal), `chat` is the - * session's default chat and only chat: it becomes a peer of the destination and the session ends. + * Applies a `move_session` move through {@link _moveChat}, the operation AHP `moveChat` and `vscode/moveSession` + * run on, so it is validated like them, and in one step of the same sequencer: placing a chat at the end reads, + * and may restore, its destination in that step, so no other move runs between that and the move. */ - private async _moveChat(session: URI, chat: URI, destination: AgentChatMoveDestination, sessionMove: boolean): Promise { + private _applyServerToolMove(move: ISessionServerToolMove): Promise { + return this._chatMoveSequencer.queue(async () => { + if (move.kind === 'session') { + const chat = await this._movedSessionChat(move.session); + return this._moveChat(move.session, chat, { kind: 'session', session: move.destination, after: await this._resolveServerToolMoveAnchor(move.destination, move.after, chat) }, true); + } + const destination = move.destination; + if (destination.kind === 'newSession') { + return this._moveChat(move.session, move.chat, destination, false); + } + return this._moveChat(move.session, move.chat, { kind: 'session', session: destination.session, after: await this._resolveServerToolMoveAnchor(destination.session, destination.after, move.chat) }, false); + }); + } + + /** Logs why the host refused a `move_session` move: the agent gets only the refusal's actionable sentence. */ + private async _logRefusedServerToolMove(move: Promise): Promise { + try { + return await move; + } catch (error) { + if (error instanceof AgentChatMoveRefusedError) { + this._logService.info(`[AgentService] move_session refused (${error.reason}): ${error.message}`); + } + throw error; + } + } + + /** The chat a moved chat is placed after: the given one, or for `end` the destination's last visible chat other than the moved one. */ + private async _resolveServerToolMoveAnchor(session: URI, after: SessionServerToolMoveAnchor, moved: URI | undefined): Promise { + if (after !== 'end') { + return after; + } + const sessionKey = session.toString(); + if (!this._stateManager.getSessionState(sessionKey)) { + try { + await this.restoreSession(session); + } catch (error) { + // The move refuses a destination it can't open, with its own reason. + this._logService.trace(`[AgentService] Can't open ${sessionKey} to place a moved chat at its end: ${toErrorMessage(error)}`); + return undefined; + } + } + const last = this._stateManager.getSessionState(sessionKey)?.chats + .filter(candidate => candidate.origin?.kind !== ChatOriginKind.Tool && candidate.interactivity !== ChatInteractivity.Hidden && candidate.resource !== moved?.toString()) + .at(-1); + return last && URI.parse(last.resource); + } + + /** + * Refuses a `move_session` move the host would refuse now, without changing anything. It runs the same checks as + * {@link _moveChat} and {@link _promoteChat} ({@link _checkChatMoveRequest}, {@link _checkPromotable} and + * {@link _validateChatMoveInputs}), short of preparing catalogs and committing. A move of the calling chat runs them + * when it is requested, so its refusals reach its agent then; the move checks everything again when it is applied. + */ + private async _checkServerToolMove(move: ISessionServerToolMove): Promise { + const sessionMove = move.kind === 'session'; + const chat = move.kind === 'session' ? await this._movedSessionChat(move.session) : move.chat; + const requested = move.kind === 'session' ? { kind: 'session' as const, session: move.destination, after: move.after } : move.destination; + // An anchor `move_session` places at the end is resolved when the move is applied; any anchor is valid until then. + const destination: AgentChatMoveDestination = requested.kind === 'newSession' + ? requested + : { kind: 'session', session: requested.session, after: requested.after === 'end' ? undefined : requested.after }; + const sourceKey = move.session.toString(); + const chatKey = chat.toString(); + const request = await this._checkChatMoveRequest(move.session, chatKey, destination, sessionMove); + if (request.kind === 'reorder') { + return; + } + if (request.kind === 'promote') { + await this._checkPromotable(chatKey); + } + const target = destination.kind === 'session' ? destination.session : undefined; + const inputs = await this._readChatMoveInputs(move.session, chatKey, target); + this._validateChatMoveInputs(sourceKey, target?.toString(), chatKey, inputs, destination.kind === 'session' ? destination.after?.toString() : undefined, sessionMove); + } + + /** + * The checks every move, reorder, promotion and session move makes before it prepares any catalog, in this order. + * Shared by {@link _moveChat}, {@link _promoteChat} and the `move_session` pre-check ({@link _checkServerToolMove}), + * so they can't drift apart. Opens a destination that isn't loaded. + */ + private async _checkChatMoveRequest(session: URI, chat: string, destination: AgentChatMoveDestination, sessionMove: boolean): Promise { const refuse = (reason: AgentChatMoveRefusal, message: string): never => { throw new AgentChatMoveRefusedError(reason, message); }; this._assertChatMovesEnabled(); - if (destination.kind === 'newSession') { - return this._promoteChat(session, chat); - } const sourceKey = session.toString(); - const chatKey = chat.toString(); - const target = destination.session; - const targetKey = target.toString(); - const after = destination.after?.toString(); - const checkMoved = () => sessionMove ? this._checkMovedSession(sourceKey, chatKey) : this._checkMovedChat(sourceKey, chatKey); // A session the host hasn't loaded since it started: its chats aren't registered until it is restored. await this._restoreForMove(session, AgentChatMoveRefusal.SessionNotReady); - checkMoved(); - if (after === chatKey) { + if (destination.kind === 'newSession') { + this._checkMovedChat(sourceKey, chat); + const provider = this._providerService.getProviderForSession(session); + if (!provider) { + return refuse(AgentChatMoveRefusal.SessionNotReady, `Session ${sourceKey} has no agent.`); + } + return { kind: 'promote', provider }; + } + if (sessionMove) { + this._checkMovedSession(sourceKey, chat); + } else { + this._checkMovedChat(sourceKey, chat); + } + const after = destination.after?.toString(); + if (after === chat) { refuse(AgentChatMoveRefusal.InvalidAnchor, 'A chat can\'t be placed after itself.'); } + const target = destination.session; + const targetKey = target.toString(); if (targetKey === sourceKey) { if (sessionMove) { refuse(AgentChatMoveRefusal.DestinationNotFound, 'A session can\'t move under itself.'); } - await this._reorderChat(session, chatKey, after); - return session; + this._checkReorderAnchor(sourceKey, after); + return { kind: 'reorder', destination }; } const listedTarget = this._stateManager.getSessionSummary(targetKey) ?? this._stateManager.getSurfacedSessionSummary(targetKey); if (listedTarget && isSessionStatusArchived(listedTarget.status)) { refuse(AgentChatMoveRefusal.SessionArchived, `Session ${targetKey} is archived.`); } await this._restoreForMove(target, AgentChatMoveRefusal.DestinationNotFound); + return { kind: 'move', destination }; + } + + /** + * Refuses promoting a chat without a conversation of its own: one with no recorded backing, or whose first turn + * hasn't started. Like a brand-new session, a chat is durable as a session only once its conversation exists: no + * agent persists one before the chat's first turn starts, and a session whose default chat has none can't be restored. + */ + private async _checkPromotable(chat: string): Promise { + if ((await this._orchestratorDatabase.getChatV2ProviderDetail(chat))?.providerData === undefined) { + // Without its own backing, a session's default chat names the session's conversation, which the new session has none of. + throw new AgentChatMoveRefusedError(AgentChatMoveRefusal.Unsupported, `Chat ${chat} has no recorded conversation of its own to take into a new session.`); + } + const started = this._hasStartedTurn(await this._resolveChatForMove(chat)); + if (!started) { + throw new AgentChatMoveRefusedError(AgentChatMoveRefusal.NotStarted, `Chat ${chat} has no turns yet; send a message before moving it to its own session.`); + } + } + + /** What a move validates against and plans its worktrees by, read before validating; `target` is `undefined` for a promotion. */ + private async _readChatMoveInputs(session: URI, chat: string, target: URI | undefined): Promise { + const sourceKey = session.toString(); + const moved = chatSubtree(this._stateManager, sourceKey, chat); + const sourceSummary = this._stateManager.getSessionState(sourceKey)?.chats.find(candidate => candidate.resource === chat); + const chatFolders = await this._movedChatFolders(session, chat); + const subtreeFolders = await this._movedSubtreeFolders(sourceKey, chat, moved, chatFolders); + const sourceWorktrees = await this._worktreeCoOwnership.readSessionWorktrees(session); + const targetPrimaryWorktree = target ? await this._worktreeCoOwnership.readPrimaryWorktree(target) : undefined; + return { moved, sourceSummary, chatFolders, subtreeFolders, sourceWorktrees, targetPrimaryWorktree }; + } + + /** + * Validates a move against the current state ({@link _validateChatMove}) and plans its worktrees; `target` is + * `undefined` for a promotion, whose new session has no older-build copy of its session values. + */ + private _validateChatMoveInputs(source: string, target: string | undefined, chat: string, inputs: IChatMoveInputs, after: string | undefined, sessionMove: boolean): IWorktreeMoveDecisions { + this._validateChatMove(source, target, chat, inputs.moved, after, inputs.chatFolders, sessionMove); + return this._planMoveWorktrees(source, chat, inputs.moved, inputs.subtreeFolders, inputs.sourceWorktrees, inputs.targetPrimaryWorktree, sessionMove, target !== undefined && this._hasOlderBuildSessionCopy(target)); + } + + /** + * Moves `chat` and its descendants out of `session`. With `sessionMove` (Block 4, VS Code-internal), `chat` is the + * session's default chat and only chat: it becomes a peer of the destination and the session ends. + */ + private async _moveChat(session: URI, chat: URI, destination: AgentChatMoveDestination, sessionMove: boolean): Promise { + const refuse = (reason: AgentChatMoveRefusal, message: string): never => { + throw new AgentChatMoveRefusedError(reason, message); + }; + const sourceKey = session.toString(); + const chatKey = chat.toString(); + const request = await this._checkChatMoveRequest(session, chatKey, destination, sessionMove); + if (request.kind === 'promote') { + return this._promoteChat(session, chat, request.provider); + } + const target = request.destination.session; + const targetKey = target.toString(); + const after = request.destination.after?.toString(); + if (request.kind === 'reorder') { + await this._reorderChat(session, chatKey, after); + return session; + } + const checkMoved = () => sessionMove ? this._checkMovedSession(sourceKey, chatKey) : this._checkMovedChat(sourceKey, chatKey); await this._prepareCatalogsForMove([sourceKey, targetKey]); // Read before validating: the database refuses the move if either catalog changes after this point. const catalogs = new Map((await this._orchestratorDatabase.readCatalogSnapshot([sourceKey, targetKey])).map(entry => [entry.session, entry.header?.revision ?? 0])); if (!this._stateManager.getSessionState(sourceKey) || !this._stateManager.getSessionState(targetKey)) { return refuse(AgentChatMoveRefusal.DestinationNotFound, `Session ${this._stateManager.getSessionState(sourceKey) ? targetKey : sourceKey} is not available.`); } - const moved = chatSubtree(this._stateManager, sourceKey, chatKey); - const sourceSummary = this._stateManager.getSessionState(sourceKey)?.chats.find(candidate => candidate.resource === chatKey); - const chatFolders = await this._movedChatFolders(session, chatKey); - const subtreeFolders = await this._movedSubtreeFolders(sourceKey, chatKey, moved, chatFolders); - const sourceWorktrees = await this._worktreeCoOwnership.readSessionWorktrees(session); - const targetPrimaryWorktree = await this._worktreeCoOwnership.readPrimaryWorktree(target); + const inputs = await this._readChatMoveInputs(session, chatKey, target); + const { moved, sourceSummary, chatFolders, targetPrimaryWorktree } = inputs; const validate = () => { checkMoved(); - this._validateChatMove(sourceKey, targetKey, chatKey, moved, after, chatFolders, sessionMove); - return this._planMoveWorktrees(sourceKey, chatKey, moved, subtreeFolders, sourceWorktrees, targetPrimaryWorktree, sessionMove, this._hasOlderBuildSessionCopy(targetKey)); + return this._validateChatMoveInputs(sourceKey, targetKey, chatKey, inputs, after, sessionMove); }; validate(); const targetFolders = this._stateManager.getSessionSummary(targetKey)?.workingDirectories ?? []; @@ -7076,43 +7262,24 @@ export class AgentService extends Disposable implements IAgentService { * Promotes a peer chat and its descendants into a new session of the same agent (AHP `moveChat` to a new session, * Block 4): the chat becomes its default chat and keeps its runtime, which relaunches with the new session's * configuration at its next turn (3c). The new session starts with the source's config and the chat's own values - * over it, the chat's folders and the folder state it carries. + * over it, the chat's folders and the folder state it carries. Runs once {@link _checkChatMoveRequest} accepted the + * request, with the source's agent it resolved as `provider`. */ - private async _promoteChat(session: URI, chat: URI): Promise { + private async _promoteChat(session: URI, chat: URI, provider: IAgent): Promise { const refuse = (reason: AgentChatMoveRefusal, message: string): never => { throw new AgentChatMoveRefusedError(reason, message); }; const sourceKey = session.toString(); const chatKey = chat.toString(); - this._checkMovedChat(sourceKey, chatKey); - const provider = this._providerService.getProviderForSession(session); - if (!provider) { - return refuse(AgentChatMoveRefusal.SessionNotReady, `Session ${sourceKey} has no agent.`); - } await this._prepareCatalogsForMove([sourceKey]); - if ((await this._orchestratorDatabase.getChatV2ProviderDetail(chatKey))?.providerData === undefined) { - // Without its own backing, a session's default chat names the session's conversation, which the new session has none of. - refuse(AgentChatMoveRefusal.Unsupported, `Chat ${chatKey} has no recorded conversation of its own to take into a new session.`); - } - // Like a brand-new session, a chat is durable as a session only once its conversation exists: no agent persists one - // before the chat's first turn starts, and a session whose default chat has none can't be restored. - const started = this._hasStartedTurn(await this._resolveChatForMove(chatKey)); - if (!started) { - refuse(AgentChatMoveRefusal.NotStarted, `Chat ${chatKey} has no turns yet; send a message before moving it to its own session.`); - } + await this._checkPromotable(chatKey); // Read before validating: the database refuses the promotion if the catalog changes after this point. const [catalog] = await this._orchestratorDatabase.readCatalogSnapshot([sourceKey]); let expectedSourceCatalogRevision = catalog?.header?.revision ?? 0; - const moved = chatSubtree(this._stateManager, sourceKey, chatKey); - const sourceSummary = this._stateManager.getSessionState(sourceKey)?.chats.find(candidate => candidate.resource === chatKey); - const chatFolders = await this._movedChatFolders(session, chatKey); - const subtreeFolders = await this._movedSubtreeFolders(sourceKey, chatKey, moved, chatFolders); - const sourceWorktrees = await this._worktreeCoOwnership.readSessionWorktrees(session); - const validate = () => { - this._validateChatMove(sourceKey, undefined, chatKey, moved, undefined, chatFolders); - // A promoted chat's session has no older-build copy of its session values: its default chat keeps its own storage. - return this._planMoveWorktrees(sourceKey, chatKey, moved, subtreeFolders, sourceWorktrees, undefined, false, false); - }; + const inputs = await this._readChatMoveInputs(session, chatKey, undefined); + const { moved, sourceSummary, chatFolders } = inputs; + // A promoted chat's session has no older-build copy of its session values: its default chat keeps its own storage. + const validate = () => this._validateChatMoveInputs(sourceKey, undefined, chatKey, inputs, undefined, false); // The new session is a worktree session when its primary folder is a worktree the promotion shares or transfers (Block 5). const promotedWorktreeFolders = withSessionWorktreeFolders(undefined, presentedWorktreeFolders(validate().presented, undefined)); const promotedRepository = sessionWorktreeFolderRepositoryRoot(promotedWorktreeFolders ?? undefined, chatFolders[0]); @@ -7603,16 +7770,18 @@ export class AgentService extends Disposable implements IAgentService { } } - /** Places a chat after `after` (first when `undefined`) within its own session. */ + /** Refuses placing a chat after `after` in `session` unless `after` is a visible chat of it (`undefined` places it first). */ + private _checkReorderAnchor(session: string, after: string | undefined): void { + const state = this._stateManager.getSessionState(session); + if (after !== undefined && !state?.chats.some(candidate => candidate.resource === after && candidate.origin?.kind !== ChatOriginKind.Tool && candidate.interactivity !== ChatInteractivity.Hidden)) { + throw new AgentChatMoveRefusedError(AgentChatMoveRefusal.InvalidAnchor, `Chat ${after} is not a visible chat of ${session}.`); + } + } + + /** Places a chat after `after` (first when `undefined`) within its own session; {@link _checkChatMoveRequest} checked the anchor. */ private async _reorderChat(session: URI, chat: string, after: string | undefined): Promise { const sessionKey = session.toString(); - const checkAnchor = () => { - const state = this._stateManager.getSessionState(sessionKey); - if (after !== undefined && !state?.chats.some(candidate => candidate.resource === after && candidate.origin?.kind !== ChatOriginKind.Tool && candidate.interactivity !== ChatInteractivity.Hidden)) { - throw new AgentChatMoveRefusedError(AgentChatMoveRefusal.InvalidAnchor, `Chat ${after} is not a visible chat of ${sessionKey}.`); - } - }; - checkAnchor(); + const checkAnchor = () => this._checkReorderAnchor(sessionKey, after); const reorder = async () => { await this._prepareCatalogsForMove([sessionKey]); // Importing older-build edits may have removed the anchor: an invalid anchor, not a conflict to retry. @@ -8332,7 +8501,7 @@ export class AgentService extends Disposable implements IAgentService { * next reload. */ private async _interleaveLocalTurns(sessionStr: string, chatUri: string, turns: readonly Turn[]): Promise { - const records = await this._localTurns.loadForChat(sessionStr, chatUri); + const records = await this._localTurns.loadForChat(sessionStr, chatUri, new Set(turns.map(turn => turn.id))); return interleaveLocalTurns(turns, records, this._logService); } @@ -8900,9 +9069,18 @@ export class AgentService extends Disposable implements IAgentService { return this._completions.triggerCharacters; } + /** + * Disposes a session once a `createSession` for it that was requested + * earlier has settled, so the provider has registered what it must tear + * down. Creation never disposes its own session, so this cannot wait on itself. + */ async disposeSession(session: URI): Promise { this._logService.trace(`[AgentService] disposeSession: ${session.toString()}`); - await this._sessionResidency.runDisposal(session, () => this._doDisposeSession(session, 'delete')); + const creation = this._sessionCreationSequencer.peek(AgentSession.id(session)); + await this._sessionResidency.runDisposal(session, async () => { + await creation?.catch(() => undefined); + await this._doDisposeSession(session, 'delete'); + }); } /** @@ -8912,7 +9090,9 @@ export class AgentService extends Disposable implements IAgentService { */ async disposeSessionIf(session: URI, validate: () => Promise, canCommit: () => boolean): Promise { this._logService.trace(`[AgentService] disposeSessionIf: ${session.toString()}`); + const creation = this._sessionCreationSequencer.peek(AgentSession.id(session)); return this._sessionResidency.runDisposal(session, async () => { + await creation?.catch(() => undefined); if (!await validate() || !canCommit()) { return false; } diff --git a/src/vs/platform/agentHost/node/agentServiceComposition.ts b/src/vs/platform/agentHost/node/agentServiceComposition.ts index b2c6030378a9a6..883447672a023d 100644 --- a/src/vs/platform/agentHost/node/agentServiceComposition.ts +++ b/src/vs/platform/agentHost/node/agentServiceComposition.ts @@ -49,6 +49,8 @@ import type { ISessionServerToolAccessor } from './shared/sessionServerTools.js' import { type IAgentServiceFoundation } from './agentServiceFoundation.js'; import { IAgentHostProviderService } from './agentHostProviderService.js'; import { ISessionWorkspaceConversionService, SessionWorkspaceConversionService } from './chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.js'; +import { AgentHostScheduledChatMoves, IAgentHostScheduledChatMoves } from './chatContributions/scheduledChatMoves/scheduledChatMovesService.js'; +import { ChatMoveConfigKey, chatMoveRootConfigSchema } from '../common/agentHostChatMove.js'; import { IAgentHostArchivedChatWorkspaceService } from './chatContributions/chatArchive/archivedChatWorkspaceService.js'; import { IAgentHostTurnTracker } from './agentHostTurnTracker.js'; import { AgentHostSessionLifecycle, type IAgentHostSessionLifecycleAccessor } from './agentHostSessionLifecycle.js'; @@ -199,10 +201,15 @@ export function createAgentServiceComposition( )); const turnTracker = accessor.get(IAgentHostTurnTracker); const workspaceConversionService: { value: ISessionWorkspaceConversionService | undefined } = { value: undefined }; + const scheduledChatMoves = owned.add(instantiationService.createInstance(AgentHostScheduledChatMoves, { + applyChatMove: move => callbackAdapter.sessionServerToolAccessor.applyChatMove(move), + })); + services.set(IAgentHostScheduledChatMoves, scheduledChatMoves); const sessionServerToolAccessor: ISessionServerToolAccessor = { ...callbackAdapter.sessionServerToolAccessor, getChatOwner: chat => chatOwnership.getChatOwner(chat), getDefaultChat: session => chatOwnership.getDefaultChat(session), + scheduleChatMove: (chat, turnId, move) => scheduledChatMoves.schedule(chat, turnId, move), requestSessionWorkspaceUpdate: (chat, turnId, workspaceFolder, isolation, credentials) => { const initiatingClientId = turnTracker.getInitiatorClientId(chat.toString(), turnId); if (!workspaceConversionService.value) { @@ -231,6 +238,17 @@ export function createAgentServiceComposition( ), ); services.set(IAgentHostServerToolService, serverToolHost); + // `move_session` is offered only while moving chats is enabled: sessions advertise it again when that changes. + let chatMovesEnabled = configurationService.getRootValue(chatMoveRootConfigSchema, ChatMoveConfigKey.Enabled) === true; + owned.add(configurationService.onDidRootConfigChange(() => { + const enabled = configurationService.getRootValue(chatMoveRootConfigSchema, ChatMoveConfigKey.Enabled) === true; + if (enabled !== chatMovesEnabled) { + chatMovesEnabled = enabled; + for (const session of stateManager.getSessionUris()) { + serverToolHost.advertise(session); + } + } + })); workspaceConversionService.value = owned.add(instantiationService.createInstance(SessionWorkspaceConversionService, { runWithChatCatalogLock: (session, operation) => agentService!.runWithChatCatalogLock(session, operation), prepareChatWorkingDirectory: callbackAdapter.sessionServerToolAccessor.prepareChatWorkingDirectory, diff --git a/src/vs/platform/agentHost/node/agentServiceFoundation.ts b/src/vs/platform/agentHost/node/agentServiceFoundation.ts index 88cea82b279bae..6e76b44d4f4060 100644 --- a/src/vs/platform/agentHost/node/agentServiceFoundation.ts +++ b/src/vs/platform/agentHost/node/agentServiceFoundation.ts @@ -51,6 +51,11 @@ export class AgentServiceCallbackAdapter implements IAgentServiceCallbackBinder getChatContext: (session, chatId) => this.value.sessionServerToolAccessor.getChatContext(session, chatId), getSessionSpawnDepth: session => this.value.sessionServerToolAccessor.getSessionSpawnDepth(session), setSessionSpawnDepth: (session, depth) => this.value.sessionServerToolAccessor.setSessionSpawnDepth(session, depth), + isChatMoveEnabled: () => this.value.sessionServerToolAccessor.isChatMoveEnabled(), + supportsChatMoves: session => this.value.sessionServerToolAccessor.supportsChatMoves(session), + checkChatMove: move => this.value.sessionServerToolAccessor.checkChatMove(move), + applyChatMove: move => this.value.sessionServerToolAccessor.applyChatMove(move), + isKnownSessionLink: (session, chatId) => this.value.sessionServerToolAccessor.isKnownSessionLink(session, chatId), }; readonly artifactServerToolAccessor: IArtifactServerToolAccessor = { diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index eac94d93ffacef..65d62d1484e247 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -1952,9 +1952,14 @@ export class AgentSideEffects extends Disposable { : action.turnId; // Route to the chat being truncated: the default chat (addressed // by the session) or a peer chat with its own backing. - agent?.truncateChat?.(URI.parse(chatChannel), sdkTurnId, this._chatContext(sessionChannel, chatChannel)).catch(err => { - this._logService.error('[AgentSideEffects] truncateChat failed', err); - }); + const truncation = agent?.truncateChat?.(URI.parse(chatChannel), sdkTurnId, this._chatContext(sessionChannel, chatChannel)); + if (truncation) { + void truncation.catch(err => { + this._logService.error('[AgentSideEffects] truncateChat failed', err); + }).finally(() => this._changesets.onSessionTruncated(sessionChannel)); + } else { + this._changesets.onSessionTruncated(sessionChannel); + } // Drop persisted local turns that no longer survive in the // (already-truncated) chat state. const survivingIds = new Set((this._stateManager.getChatState(chatChannel)?.turns ?? []).map(t => t.id)); @@ -1970,7 +1975,6 @@ export class AgentSideEffects extends Disposable { trackedTurnIds.add(activeTurnId); } this._turnTracker.clearTurnsExcept(chatChannel, trackedTurnIds); - this._changesets.onSessionTruncated(sessionChannel); break; } case ActionType.SessionActiveClientSet: { diff --git a/src/vs/platform/agentHost/node/chatContributions/builtInChatContributions.ts b/src/vs/platform/agentHost/node/chatContributions/builtInChatContributions.ts index dacf3eba7eaf74..afb5255dd73368 100644 --- a/src/vs/platform/agentHost/node/chatContributions/builtInChatContributions.ts +++ b/src/vs/platform/agentHost/node/chatContributions/builtInChatContributions.ts @@ -26,6 +26,7 @@ import { PullRequestChatContribution } from './pullRequest/pullRequestChatContri import { SessionWorkspaceConversionContribution } from './sessionWorkspaceConversion/sessionWorkspaceConversionContribution.js'; import { QueueDrainContribution } from './queueDrain/queueDrainContribution.js'; import { RemoteSessionOriginContribution } from './remoteSessionOrigin/remoteSessionOriginContribution.js'; +import { ScheduledChatMovesContribution } from './scheduledChatMoves/scheduledChatMovesContribution.js'; import { SessionFlagsContribution } from './sessionFlags/sessionFlagsContribution.js'; import { SessionInputNeededContribution } from './sessionInputNeeded/sessionInputNeededContribution.js'; import { SessionTitleContribution } from './sessionTitle/sessionTitleContribution.js'; @@ -55,6 +56,7 @@ export function registerBuiltInChatContributions( registrations.add(contributions.registerContribution(WorktreeAnnouncementContribution)); registrations.add(contributions.registerContribution(CheckpointAndChangesetContribution)); registrations.add(contributions.registerContribution(SessionWorkspaceConversionContribution)); + registrations.add(contributions.registerContribution(ScheduledChatMovesContribution)); registrations.add(contributions.registerContribution(QueueDrainContribution)); registrations.add(contributions.registerContribution(SessionInputNeededContribution)); registrations.add(contributions.registerContribution(GitHubReferencesContribution)); diff --git a/src/vs/platform/agentHost/node/chatContributions/checkpointAndChangeset/checkpointAndChangesetContribution.ts b/src/vs/platform/agentHost/node/chatContributions/checkpointAndChangeset/checkpointAndChangesetContribution.ts index e050e8c0343864..84f5ad4fde853d 100644 --- a/src/vs/platform/agentHost/node/chatContributions/checkpointAndChangeset/checkpointAndChangesetContribution.ts +++ b/src/vs/platform/agentHost/node/chatContributions/checkpointAndChangeset/checkpointAndChangesetContribution.ts @@ -28,6 +28,11 @@ export class CheckpointAndChangesetContribution extends Disposable implements IA } onTurnEnd(turn: ITurnEnd): void { + if (turn.reason.kind === 'cancelled') { + // Edits already applied still belong in turn and session changes after the model stops. + this._onTurnComplete(turn); + return; + } if (turn.reason.kind !== 'success' && turn.reason.kind !== 'error') { return; } diff --git a/src/vs/platform/agentHost/node/chatContributions/scheduledChatMoves/scheduledChatMovesContribution.ts b/src/vs/platform/agentHost/node/chatContributions/scheduledChatMoves/scheduledChatMovesContribution.ts new file mode 100644 index 00000000000000..30024b8b5252fd --- /dev/null +++ b/src/vs/platform/agentHost/node/chatContributions/scheduledChatMoves/scheduledChatMovesContribution.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 { Disposable } from '../../../../../base/common/lifecycle.js'; +import type { IAgentHostChatContribution, IAgentHostChatContributionContext, ITurnEnd } from '../../../common/agentHostChatContributionsService.js'; +import { IAgentHostScheduledChatMoves } from './scheduledChatMovesService.js'; + +/** + * Applies a move of the calling chat that `move_session` scheduled once the requesting turn succeeded, and drops it + * when the turn ended any other way. Runs after a workspace change the same turn requested (150) and before queued + * turns drain (200). + */ +export class ScheduledChatMovesContribution extends Disposable implements IAgentHostChatContribution { + + static readonly id = 'scheduledChatMoves'; + readonly order = 160; + + constructor( + protected readonly _context: IAgentHostChatContributionContext, + @IAgentHostScheduledChatMoves private readonly _scheduledMoves: IAgentHostScheduledChatMoves, + ) { + super(); + } + + onTurnEnd(turn: ITurnEnd): void { + // A refused request never started a turn, so no move was scheduled for it. + if (turn.reason.kind === 'rejected') { + return; + } + this._scheduledMoves.turnEnded(turn.channel, turn.turnId, turn.reason.kind === 'success'); + } +} diff --git a/src/vs/platform/agentHost/node/chatContributions/scheduledChatMoves/scheduledChatMovesService.ts b/src/vs/platform/agentHost/node/chatContributions/scheduledChatMoves/scheduledChatMovesService.ts new file mode 100644 index 00000000000000..4148858c05b0aa --- /dev/null +++ b/src/vs/platform/agentHost/node/chatContributions/scheduledChatMoves/scheduledChatMovesService.ts @@ -0,0 +1,87 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { toErrorMessage } from '../../../../../base/common/errorMessage.js'; +import { Disposable, type IDisposable } from '../../../../../base/common/lifecycle.js'; +import type { URI } from '../../../../../base/common/uri.js'; +import { createDecorator } from '../../../../instantiation/common/instantiation.js'; +import { ILogService } from '../../../../log/common/log.js'; +import type { URI as ProtocolURI } from '../../../common/state/sessionState.js'; +import { AgentHostStateManager, IAgentHostStateManager } from '../../agentHostStateManager.js'; +import type { ISessionServerToolMove } from '../../shared/sessionServerTools.js'; + +export const IAgentHostScheduledChatMoves = createDecorator('agentHostScheduledChatMoves'); + +/** + * Moves of a calling chat that `move_session` requested, applied when the requesting turn ends, like a workspace + * change `set_workspace` requests. A move of any other chat is applied at once. + */ +export interface IAgentHostScheduledChatMoves { + readonly _serviceBrand: undefined; + /** Schedules {@link move} for when turn {@link turnId} of {@link chat} ends; refuses a second move for that turn. */ + schedule(chat: ProtocolURI, turnId: string, move: ISessionServerToolMove): void; + /** + * Applies the move turn {@link turnId} of {@link chat} scheduled when the turn succeeded, and drops it otherwise. + * The chat's queued turns are held from this call until the move is done, so a turn that ending makes start (queue + * drain runs later in the same turn end) starts once the chat is where it moved to. + */ + turnEnded(chat: ProtocolURI, turnId: string | undefined, succeeded: boolean): void; +} + +/** What applies a scheduled move: the host operation `move_session` applies any other move with. */ +export interface IAgentHostScheduledChatMoveHost { + applyChatMove(move: ISessionServerToolMove): Promise; +} + +/** Holds at most one move per chat, for the turn that requested it. */ +export class AgentHostScheduledChatMoves extends Disposable implements IAgentHostScheduledChatMoves { + + declare readonly _serviceBrand: undefined; + + private readonly _scheduled = new Map(); + + constructor( + private readonly _host: IAgentHostScheduledChatMoveHost, + @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager, + @ILogService private readonly _logService: ILogService, + ) { + super(); + // A moved chat stays registered; a deleted one, or one of a removed session, takes its move along. + this._register(this._stateManager.onDidUnregisterChat(({ chat }) => this._scheduled.delete(chat))); + } + + schedule(chat: ProtocolURI, turnId: string, move: ISessionServerToolMove): void { + if (this._scheduled.get(chat)?.turnId === turnId) { + throw new Error('A move of this chat is already scheduled for when this turn ends.'); + } + this._scheduled.set(chat, { turnId, move }); + } + + turnEnded(chat: ProtocolURI, turnId: string | undefined, succeeded: boolean): void { + const scheduled = this._scheduled.get(chat); + if (!scheduled || scheduled.turnId !== turnId) { + return; + } + this._scheduled.delete(chat); + if (!succeeded) { + this._logService.info(`[AgentHostScheduledChatMoves] Dropped the move of ${chat}: its turn ${turnId} did not complete.`); + return; + } + // Taken now, before this turn end's queue drain: the move itself also holds the chat, but only once it runs. + const admission = this._stateManager.holdChatAdmission([chat]); + void this._apply(chat, turnId, scheduled.move, admission); + } + + private async _apply(chat: ProtocolURI, turnId: string | undefined, move: ISessionServerToolMove, admission: IDisposable): Promise { + try { + await this._host.applyChatMove(move); + } catch (error) { + // The host logs why it refused the move; the chat goes on where it is. + this._logService.warn(`[AgentHostScheduledChatMoves] The move of ${chat} requested in turn ${turnId} was not applied: ${toErrorMessage(error)}`); + } finally { + admission.dispose(); + } + } +} diff --git a/src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts b/src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts index 387ee9eef08bd5..9d28f41e4efd9c 100644 --- a/src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts +++ b/src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts @@ -147,7 +147,7 @@ async function dispatchCanUseTool( const permissionKind = getClaudePermissionKind(toolName); const displayName = getClaudeToolDisplayName(toolName); const permissionPath = options.blockedPath ?? getClaudeToolPath(toolName, input); - const serverDisplay = serverToolName ? getServerToolDisplay(serverToolName, input) : undefined; + const serverDisplay = serverToolName ? serverToolHost?.getConfirmationDisplay(serverToolName, input) ?? getServerToolDisplay(serverToolName, input) : undefined; const toolInputString = serverDisplay?.hideConfirmationInput ? undefined : getClaudeToolInputString(toolName, input); const meta = buildClaudeToolMeta(toolName); const state: ToolCallPendingConfirmationState = { diff --git a/src/vs/platform/agentHost/node/codex/codexAgent.ts b/src/vs/platform/agentHost/node/codex/codexAgent.ts index e138213e51ae75..a0ae34d33370e9 100644 --- a/src/vs/platform/agentHost/node/codex/codexAgent.ts +++ b/src/vs/platform/agentHost/node/codex/codexAgent.ts @@ -27,7 +27,7 @@ import { ILogService } from '../../../log/common/log.js'; import { IProductService } from '../../../product/common/productService.js'; import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; import { IAgentHostStartupPerformance } from '../agentHostStartupPerformance.js'; -import { createSchema, platformRootSchema, platformSessionSchema, schemaProperty, AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostGitHubMcpServerEnabledConfigKey, AgentHostMcpServersConfigKey, AgentHostWorkspaceTrustConfigKey, type ISchemaProperty, type SessionMode } from '../../common/agentHostSchema.js'; +import { createSchema, platformRootSchema, platformSessionSchema, schemaProperty, AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostGitHubMcpServerEnabledConfigKey, AgentHostMcpServersConfigKey, AgentHostWorkspaceTrustConfigKey, areAllAgentHostWorkspaceResourcesTrusted, type ISchemaProperty, type SessionMode } from '../../common/agentHostSchema.js'; 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'; @@ -102,6 +102,9 @@ import { resolveCodexInput } from './codexPromptResolver.js'; import { buildUserInputRequest, emptyUserInputResponse, userInputResponseFromAnswers } from './codexUserInputMapper.js'; import { replayThreadToTurns, type ICodexReplayedCommand } from './codexReplayMapper.js'; import { codexRetainedCommandOutputContent, shouldRetainCodexCommandOutput } from './codexTerminalOutput.js'; +import { storeCodexTurnDiffs } from './codexTurnDiff.js'; +import { CodexTurnDiffTracker } from './codexTurnDiffTracker.js'; +import type { TurnDiffUpdatedNotification } from './protocol/generated/v2/TurnDiffUpdatedNotification.js'; import { buildNonPtyShellTerminalUri } from '../../common/nonPtyShellTerminalUri.js'; import { VSBuffer } from '../../../../base/common/buffer.js'; import { CodexSessionMetadataStore } from './codexSessionMetadataStore.js'; @@ -915,6 +918,8 @@ interface ICodexSubagent { readonly parentSessionId: string; /** Host-side toolCallId of the parent `spawnAgent` collab tool call (routing key). */ readonly toolCallId: string; + /** The host turn that spawned the child, retained across parent cancellation and replacement sends. */ + readonly hostTurnId: string; /** * Isolated session used to run the shared event mappers for the child * thread. Shares the parent's `sessionUri` and `acceptedForSession` memo so @@ -1115,6 +1120,9 @@ export class CodexAgent extends Disposable implements IAgent { readonly onDidChatProgress = this._onDidChatProgress.event; private readonly _pendingCommandOutputs = new WeakMap>>(); private readonly _pendingChatProgress = new WeakMap>(); + private readonly _turnDiffs = new WeakMap(); + private readonly _onDidStoreTurnEdits = this._register(new Emitter<{ readonly chat: URI; readonly turnIds: readonly string[] }>()); + readonly onDidStoreTurnEdits = this._onDidStoreTurnEdits.event; private readonly _onDidMaterializeChat = this._register(new Emitter()); readonly onDidMaterializeChat = this._onDidMaterializeChat.event; @@ -2722,6 +2730,7 @@ export class CodexAgent extends Disposable implements IAgent { })); subscriptions.add(client.onNotification('skills/changed', () => this._queueSkillHookCustomizationRefresh(client))); subscriptions.add(client.onNotification('turn/started', params => this._dispatchByThread(params.threadId, s => this._handleTurnStartedNotification(s, params)))); + subscriptions.add(client.onNotification('turn/diff/updated', params => this._dispatchByThread(params.threadId, s => this._handleTurnDiffUpdated(s, params)))); subscriptions.add(client.onNotification('item/started', params => this._dispatchByThread(params.threadId, s => this._handleItemStarted(s, params)))); subscriptions.add(client.onNotification('item/agentMessage/delta', params => this._dispatchByThread(params.threadId, s => mapAgentMessageDelta(s.mapState, this._withHostTurnId(s, params))))); subscriptions.add(client.onNotification('item/commandExecution/outputDelta', params => this._dispatchByThread(params.threadId, s => mapCommandExecutionOutputDelta(s.mapState, this._withHostTurnId(s, params))))); @@ -2991,15 +3000,16 @@ export class CodexAgent extends Disposable implements IAgent { if (!entry) { return { result: this._toolFailure(`No pending server tool call for ${params.tool} (callId ${params.callId})`) }; } + const confirmation = host.getConfirmationDisplay(params.tool, params.arguments); const display = getServerToolDisplay(params.tool, params.arguments); - const invocationMessage = display?.confirmationMessage ?? display?.invocationMessage ?? `Calling ${params.tool}`; + const invocationMessage = confirmation?.confirmationMessage ?? display?.confirmationMessage ?? display?.invocationMessage ?? `Calling ${params.tool}`; const decision = await session.pendingCommandApprovals.registerAndFire(entry.toolCallId, () => { this._fire(session.sessionUri, { type: ActionType.ChatToolCallReady, turnId: entry.turnId, toolCallId: entry.toolCallId, invocationMessage, - confirmationTitle: display?.confirmationTitle ?? localize('codex.serverToolConfirmation.title', "Allow tool call?"), + confirmationTitle: confirmation?.confirmationTitle ?? display?.confirmationTitle ?? localize('codex.serverToolConfirmation.title', "Allow tool call?"), }); }); if (decision !== 'accept' && decision !== 'acceptForSession') { @@ -3176,6 +3186,12 @@ export class CodexAgent extends Disposable implements IAgent { } this._updateTurnReviewer(session); const mapped = this._withHostTurn(session, params); + const subagent = this._subagentsByThreadId.get(params.threadId); + const owner = this._turnDiffOwner(session); + if (owner) { + this._getTurnDiffTracker(owner).start(params.threadId, appTurnId, subagent?.hostTurnId ?? mapped.turn.id); + } + session.codexTurnIdByHostTurnId.set(mapped.turn.id, appTurnId); this._persistTurnEventId(session, mapped.turn.id, appTurnId); mapTurnStarted(session.mapState, mapped, session.lastPromptText); return []; @@ -3193,6 +3209,74 @@ export class CodexAgent extends Disposable implements IAgent { }).finally(() => ref.dispose()); } + private _handleTurnDiffUpdated(session: ICodexSession, params: TurnDiffUpdatedNotification): (SessionAction | ChatAction)[] { + const owner = this._turnDiffOwner(session); + if (owner) { + this._turnDiffs.get(owner)?.update(params); + } + return []; + } + + private _turnDiffOwner(session: ICodexSession): ICodexSession | undefined { + const subagent = session.threadId ? this._subagentsByThreadId.get(session.threadId) : undefined; + return subagent ? this._sessions.get(subagent.parentSessionId) : session; + } + + private _getTurnDiffTracker(session: ICodexSession): CodexTurnDiffTracker { + let tracker = this._turnDiffs.get(session); + if (!tracker) { + tracker = new CodexTurnDiffTracker(async (captures, relatedDiffs) => { + if (!session.chatStorage || !session.workingDirectory || session.disposed) { + return; + } + const database = this._sessionDataService.openDatabase(session.chatStorage); + const cwd = session.workingDirectory; + try { + const nativeCwd = URI.file(await fs.promises.realpath(cwd.fsPath).catch(() => cwd.fsPath)); + await storeCodexTurnDiffs(captures, { + relatedDiffs, cwd, nativeCwd, database: database.object, fileService: this._fileService, logService: this._logService, + }); + } finally { + database.dispose(); + } + if (session.chatChannel) { + this._onDidStoreTurnEdits.fire({ chat: session.chatChannel, turnIds: [...new Set(captures.flatMap(capture => capture.snapshots.map(snapshot => snapshot.turnId)))] }); + } + }); + this._turnDiffs.set(session, tracker); + } + return tracker; + } + + async waitForTurnEdits(chat: URI, turnId: string): Promise { + const sessionId = this._sessionIdByChatUri.get(chat.toString()); + const session = sessionId ? this._sessions.get(sessionId) : undefined; + if (session) { + const tracker = this._turnDiffs.get(session); + const ready = await raceTimeout(tracker?.waitForTurnEdits(turnId).then(() => true) ?? Promise.resolve(true), 30_000); + if (!ready) { + const error = new Error(localize('codex.turnSnapshotsTimeout', "Timed out waiting for Codex file snapshots.")); + tracker?.failWaitingTurn(turnId, error); + throw error; + } + } + } + + /** Completes native capture without blocking child progress; changeset reads wait on the tracker. */ + private _stageTurnDiff(session: ICodexSession, appTurnId: string, publisher = session): void { + const tracker = this._turnDiffs.get(publisher); + if (!tracker) { + return; + } + const captured = tracker.complete(appTurnId).catch(error => { + this._logService.warn(`[Codex:${session.threadId}] Failed to capture turn diff`, error); + }); + if (!tracker.isWaitingForRelatedTurns(appTurnId)) { + const previous = this._pendingChatProgress.get(publisher); + this._pendingChatProgress.set(publisher, Promise.all([previous, captured]).then(() => { })); + } + } + private _handleTurnCompletedNotification(session: ICodexSession, params: TurnCompletedNotification, retainRecoveredOutput = true): (SessionAction | ChatAction)[] { const appTurnId = params.turn.id; const hostTurnId = this._hostTurnId(session, appTurnId); @@ -3316,7 +3400,10 @@ export class CodexAgent extends Disposable implements IAgent { } const newHostTurnId = generateUuid(); if (appTurnId) { + this._turnDiffs.get(session)?.split(appTurnId, newHostTurnId); session.hostTurnIdByAppTurnId.set(appTurnId, newHostTurnId); + session.codexTurnIdByHostTurnId.set(newHostTurnId, appTurnId); + this._persistTurnEventId(session, newHostTurnId, appTurnId); } session.currentTurnId = newHostTurnId; actions.push({ @@ -3691,6 +3778,7 @@ export class CodexAgent extends Disposable implements IAgent { private _dispatchItemCompleted(params: ItemCompletedNotification): void { const subagent = this._subagentsByThreadId.get(params.threadId); if (subagent) { + this._recordTurnFileChanges(subagent.session, params); const actions = mapItemCompleted(subagent.session.mapState, this._withHostTurnId(subagent.session, params)); for (const action of actions) { this._fireSubagent(subagent, action); @@ -3708,6 +3796,7 @@ export class CodexAgent extends Disposable implements IAgent { // may clear), and firing `subagent_started` first lets the orchestrator // attach the child-conversation block to the still-open parent tool call. this._maybeRegisterSubagents(session, params); + this._recordTurnFileChanges(session, params); const retainedOutputResource = this._retainCommandOutput(session, params.item); const actions = mapItemCompleted(session.mapState, this._withHostTurnId(session, params), retainedOutputResource); for (const action of actions) { @@ -3715,6 +3804,21 @@ export class CodexAgent extends Disposable implements IAgent { } } + /** Uses native file-change notifications only for path ordering; contents come from cumulative turn diffs. */ + private _recordTurnFileChanges(session: ICodexSession, params: ItemCompletedNotification): void { + if (params.item.type !== 'fileChange' || params.item.status !== 'completed') { + return; + } + const owner = this._turnDiffOwner(session); + if (owner) { + const tracker = this._getTurnDiffTracker(owner); + const child = this._subagentsByThreadId.get(params.threadId); + tracker.start(params.threadId, params.turnId, child?.hostTurnId ?? this._hostTurnId(session, params.turnId)); + tracker.markEdited(params.turnId, params.item.changes.flatMap(change => change.kind.type === 'update' && change.kind.move_path + ? [change.path, change.kind.move_path] : [change.path])); + } + } + /** Queues retention; publication waits for the write and keeps the full inline output if it fails. */ private _retainCommandOutput(session: ICodexSession, item: ItemCompletedNotification['item']): string | undefined { if (item.type !== 'commandExecution') { @@ -3765,6 +3869,11 @@ export class CodexAgent extends Disposable implements IAgent { private _dispatchTurnCompleted(params: TurnCompletedNotification): void { const subagent = this._subagentsByThreadId.get(params.threadId); if (subagent) { + const parent = this._sessions.get(subagent.parentSessionId); + if (parent) { + this._getTurnDiffTracker(parent).start(params.threadId, params.turn.id, subagent.hostTurnId); + this._stageTurnDiff(subagent.session, params.turn.id, parent); + } const actions = this._handleTurnCompletedNotification(subagent.session, params, false); for (const action of actions) { if (action.type === ActionType.ChatTurnComplete) { @@ -3781,7 +3890,10 @@ export class CodexAgent extends Disposable implements IAgent { }); return; } - this._dispatchByThread(params.threadId, s => this._handleTurnCompletedNotification(s, params)); + this._dispatchByThread(params.threadId, s => { + this._stageTurnDiff(s, params.turn.id); + return this._handleTurnCompletedNotification(s, params); + }); } /** @@ -3809,9 +3921,13 @@ export class CodexAgent extends Disposable implements IAgent { continue; } const subSession = this._createSubagentSession(session, childThreadId); + const spawningChild = session.threadId ? this._subagentsByThreadId.get(session.threadId) : undefined; + const hostTurnId = spawningChild?.hostTurnId ?? this._hostTurnId(session, params.turnId); + this._getTurnDiffTracker(this._turnDiffOwner(session) ?? session).trackChild(childThreadId, hostTurnId); this._subagentsByThreadId.set(childThreadId, { parentSessionId: session.sessionId, toolCallId: entry.toolCallId, + hostTurnId, session: subSession, }); this._emitChatProgress(session, { @@ -4266,6 +4382,14 @@ export class CodexAgent extends Disposable implements IAgent { this._disposeConnectionResources(connection); return; } + for (const subagent of this._subagentsByThreadId.values()) { + const parent = this._sessions.get(subagent.parentSessionId); + if (parent && subagent.session.threadId) { + void this._turnDiffs.get(parent)?.completeThread(subagent.session.threadId).catch(error => { + this._logService.warn('[Codex] Failed to capture child edits on disconnect', error); + }); + } + } // Notify every known session with a single ChatError + complete // pair so the UI surfaces "agent disconnected" cleanly. for (const session of this._sessions.values()) { @@ -4288,6 +4412,9 @@ export class CodexAgent extends Disposable implements IAgent { this._drainPendingSteering(session); const turnId = session.currentTurnId; const appTurnId = session.currentAppTurnId; + if (appTurnId) { + this._stageTurnDiff(session, appTurnId); + } session.currentTurnId = undefined; session.currentAppTurnId = undefined; session.agentMergeTurn = false; @@ -6614,6 +6741,7 @@ export class CodexAgent extends Disposable implements IAgent { */ private async _teardownSessionInMemory(session: ICodexSession, sessionId: string, deleteManagedWorkingDirectory: boolean): Promise { session.disposed = true; + this._turnDiffs.get(session)?.dispose(); this._workingDirectoryMutations.get(session)?.updated.cancel(); this._claimPrewarm(session); this._sessions.delete(sessionId); @@ -6658,6 +6786,7 @@ export class CodexAgent extends Disposable implements IAgent { // orchestrator closes the child conversations as part of session teardown. for (const [childThreadId, subagent] of this._subagentsByThreadId) { if (subagent.parentSessionId === sessionId) { + this._turnDiffs.get(subagent.session)?.dispose(); subagent.session.pendingCommandApprovals.denyAll('decline'); this._subagentsByThreadId.delete(childThreadId); } @@ -6754,52 +6883,32 @@ export class CodexAgent extends Disposable implements IAgent { } firstTurnToRemove = index + 1; } - if (firstTurnToRemove >= turns.length) { - return; - } - if (read.thread.historyMode === 'legacy') { - throw new Error(localize('codex.legacyThreadTruncation', "This version of Codex cannot remove messages from legacy chats.")); - } - try { - const conn = targetSession - ? (await this._ensureThreadConnection(targetSession)).connection - : await this._ensureConnection(); - await conn.client.request<'thread/revert'>('thread/revert', { - threadId: read.thread.id, - beforeTurnId: turns[firstTurnToRemove].id, - }); - } catch (err) { - this._logService.warn(`[Codex:${read.thread.id}] thread/revert failed: ${err instanceof Error ? err.message : String(err)}`); - return; + if (firstTurnToRemove < turns.length) { + if (read.thread.historyMode === 'legacy') { + throw new Error(localize('codex.legacyThreadTruncation', "This version of Codex cannot remove messages from legacy chats.")); + } + try { + const conn = targetSession + ? (await this._ensureThreadConnection(targetSession)).connection + : await this._ensureConnection(); + await conn.client.request<'thread/revert'>('thread/revert', { + threadId: read.thread.id, + beforeTurnId: turns[firstTurnToRemove].id, + }); + } catch (err) { + this._logService.warn(`[Codex:${read.thread.id}] thread/revert failed: ${err instanceof Error ? err.message : String(err)}`); + return; + } } const storage = context ? resolveAgentChatContext(context, chat).resource : targetSession?.chatStorage; if (storage) { - await this._deleteRetainedCommandOutputs(storage, turns.slice(firstTurnToRemove)); - } - } - - /** - * Removes output retained for the commands of turns removed from the - * thread. Codex keeps the chat's database turns, so their retained output - * would otherwise stay until the chat is deleted. - */ - private async _deleteRetainedCommandOutputs(storage: URI, turns: Thread['turns']): Promise { - const toolCallIds = turns.flatMap(turn => (turn.items ?? []).flatMap(item => item.type === 'commandExecution' ? [item.id] : [])); - if (toolCallIds.length === 0) { - return; - } - try { - const database = await this._sessionDataService.tryOpenDatabase(storage); - if (!database) { - return; - } + const database = this._sessionDataService.openDatabase(storage); try { - await Promise.all(toolCallIds.map(toolCallId => database.object.deleteTerminalOutput(toolCallId))); + const afterHostTurnId = turnId !== undefined && targetSession?.codexTurnIdByHostTurnId.has(turnId) ? turnId : undefined; + await database.object.deleteProviderTurns(turns.slice(firstTurnToRemove).map(turn => turn.id), afterHostTurnId); } finally { database.dispose(); } - } catch (error) { - this._logService.warn(`[Codex] Failed to remove retained command output in ${storage.toString()}`, error); } } @@ -8234,7 +8343,7 @@ export class CodexAgent extends Disposable implements IAgent { if (!trust) { return false; } - if (!trust.enabled) { + if (areAllAgentHostWorkspaceResourcesTrusted(trust)) { return true; } return trust.trustedUris.some(uri => { @@ -8779,6 +8888,7 @@ export class CodexAgent extends Disposable implements IAgent { private _clearRuntimeState(): void { for (const s of this._sessions.values()) { s.disposed = true; + this._turnDiffs.get(s)?.dispose(); this._workingDirectoryMutations.get(s)?.updated.cancel(); if (s.prewarmTimer) { clearTimeout(s.prewarmTimer); @@ -8791,6 +8901,7 @@ export class CodexAgent extends Disposable implements IAgent { } for (const subagent of this._subagentsByThreadId.values()) { subagent.session.disposed = true; + this._turnDiffs.get(subagent.session)?.dispose(); if (subagent.session.prewarmTimer) { clearTimeout(subagent.session.prewarmTimer); subagent.session.prewarmTimer = undefined; @@ -8932,7 +9043,7 @@ export class CodexAgent extends Disposable implements IAgent { this._emitChatProgress(session, { kind: 'action', resource: sessionUri, action }); } - /** Keeps mapping synchronous while publishing each chat's signals in order after pending output writes. */ + /** Keeps mapping synchronous while publishing each chat's signals in order after pending snapshot and output writes. */ private _emitChatProgress(session: ICodexSession | undefined, signal: AgentSignal): void { const completion = signal.kind === 'action' && !signal.parentToolCallId && signal.action.type === ActionType.ChatToolCallComplete ? signal.action : undefined; const outputs = session && this._pendingCommandOutputs.get(session); diff --git a/src/vs/platform/agentHost/node/codex/codexLaunchConfig.ts b/src/vs/platform/agentHost/node/codex/codexLaunchConfig.ts index 91ce3634143d93..d9d07126192914 100644 --- a/src/vs/platform/agentHost/node/codex/codexLaunchConfig.ts +++ b/src/vs/platform/agentHost/node/codex/codexLaunchConfig.ts @@ -146,7 +146,8 @@ export function buildCodexLaunchConfig( const telemetryOverrides = codexTelemetryOverrides(telemetry); return { env, - args: ['app-server', ...overrides.flatMap(value => ['-c', value]), ...extraArgs, ...permissionOverrides.flatMap(value => ['-c', value]), ...telemetryOverrides.flatMap(value => ['-c', value])], + // The snapshot parser resolves paths against the runtime cwd. Codex otherwise emits repository-relative paths. + args: ['app-server', ...overrides.flatMap(value => ['-c', value]), ...extraArgs, '-c', 'features.cwd_relative_turn_diffs=true', ...permissionOverrides.flatMap(value => ['-c', value]), ...telemetryOverrides.flatMap(value => ['-c', value])], }; } diff --git a/src/vs/platform/agentHost/node/codex/codexTurnDiff.ts b/src/vs/platform/agentHost/node/codex/codexTurnDiff.ts new file mode 100644 index 00000000000000..050f22f917d6a0 --- /dev/null +++ b/src/vs/platform/agentHost/node/codex/codexTurnDiff.ts @@ -0,0 +1,300 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createHash } from 'crypto'; +import { VSBuffer } from '../../../../base/common/buffer.js'; +import { relative, resolve } from '../../../../base/common/path.js'; +import { URI } from '../../../../base/common/uri.js'; +import { FileOperationResult, toFileOperationResult, type IFileService } from '../../../files/common/files.js'; +import type { ILogService } from '../../../log/common/log.js'; +import type { IFileEditContent, ISessionDatabase } from '../../common/sessionDataService.js'; +import { FileEditKind } from '../../common/state/sessionState.js'; +import type { TurnDiffUpdatedNotification } from './protocol/generated/v2/TurnDiffUpdatedNotification.js'; + +interface IDiffHunk { + readonly beforeStart: number; + readonly afterStart: number; + readonly before: string[]; + readonly after: string[]; +} + +interface IFileDiff { + readonly beforePath: string | undefined; + readonly afterPath: string | undefined; + readonly beforeHash: string; + readonly afterHash: string; + readonly hunks: readonly IDiffHunk[]; +} + +export interface ICodexTurnDiffSnapshot { + readonly turnId: string; + readonly diff: string; + readonly startSequence: number; + readonly sequence: number; + readonly fileSequences: ReadonlyMap; +} + +export interface ICodexTurnDiffCapture { + readonly notification: TurnDiffUpdatedNotification; + readonly snapshots: readonly ICodexTurnDiffSnapshot[]; + readonly initialDiffs: readonly string[]; +} + +function contentLines(content: string): string[] { + return content.match(/[^\n]*\n|[^\n]+$/g) ?? []; +} + +function blobHash(content: string): string { + const bytes = VSBuffer.fromString(content); + return createHash('sha1').update(`blob ${bytes.byteLength}\0`).update(bytes.buffer).digest('hex'); +} + +/** Parses the git-style, three-context-line diffs emitted by Codex's turn diff tracker. */ +function parseTurnDiff(diff: string, cwd: URI): IFileDiff[] { + return diff.split(/(?=^diff --git )/m).filter(section => section.length > 0).map(section => { + const lines = section.split('\n'); + const firstHunk = lines.findIndex(line => line.startsWith('@@ ')); + const headers = firstHunk < 0 ? lines : lines.slice(0, firstHunk); + const hashes = /^index ([a-f0-9]{40})\.\.([a-f0-9]{40})(?: .*)?$/.exec(headers.find(line => line.startsWith('index ')) ?? ''); + if (!lines[0].startsWith('diff --git ') || !hashes) { + throw new Error('Invalid Codex turn diff headers'); + } + // Empty-file creates/deletes have no hunks or ---/+++ headers. + const emptyPath = /^diff --git a\/(.+) b\/\1$/.exec(lines[0])?.[1]; + const beforeHeader = headers.find(line => line.startsWith('--- '))?.slice(4) + ?? (headers.includes('new file mode 100644') ? '/dev/null' : emptyPath && `a/${emptyPath}`); + const afterHeader = headers.find(line => line.startsWith('+++ '))?.slice(4) + ?? (headers.includes('deleted file mode 100644') ? '/dev/null' : emptyPath && `b/${emptyPath}`); + const path = (header: string | undefined, prefix: string): string | undefined => { + if (header === '/dev/null') { + return undefined; + } + if (!header?.startsWith(prefix) || header.length === prefix.length) { + throw new Error('Invalid Codex turn diff path'); + } + return resolve(cwd.fsPath, header.slice(prefix.length)); + }; + const beforePath = path(beforeHeader, 'a/'); + const afterPath = path(afterHeader, 'b/'); + if (!beforePath && !afterPath) { + throw new Error('Codex turn diff has no file'); + } + const hunks: IDiffHunk[] = []; + for (let index = firstHunk; index >= 0 && index < lines.length; index++) { + if (index === lines.length - 1 && lines[index] === '') { + break; + } + const header = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(lines[index]); + if (!header) { + throw new Error('Invalid Codex turn diff hunk'); + } + const beforeCount = Number(header[2] ?? 1); + const afterCount = Number(header[4] ?? 1); + const hunk: IDiffHunk = { + beforeStart: Number(header[1]) - (beforeCount > 0 ? 1 : 0), + afterStart: Number(header[3]) - (afterCount > 0 ? 1 : 0), + before: [], after: [], + }; + let lastKind: string | undefined; + while (index + 1 < lines.length && !lines[index + 1].startsWith('@@ ') && lines[index + 1] !== '') { + const line = lines[++index]; + if (line === '\\ No newline at end of file') { + if (lastKind === ' ' || lastKind === '-') { + hunk.before[hunk.before.length - 1] = hunk.before.at(-1)!.slice(0, -1); + } + if (lastKind === ' ' || lastKind === '+') { + hunk.after[hunk.after.length - 1] = hunk.after.at(-1)!.slice(0, -1); + } + lastKind = undefined; + continue; + } + lastKind = line[0]; + if (lastKind !== ' ' && lastKind !== '+' && lastKind !== '-') { + throw new Error('Invalid Codex turn diff line'); + } + if (lastKind !== '+') { + hunk.before.push(line.slice(1) + '\n'); + } + if (lastKind !== '-') { + hunk.after.push(line.slice(1) + '\n'); + } + } + if (hunk.before.length !== beforeCount || hunk.after.length !== afterCount) { + throw new Error('Invalid Codex turn diff line counts'); + } + hunks.push(hunk); + } + return { beforePath, afterPath, beforeHash: hashes[1], afterHash: hashes[2], hunks }; + }); +} + +/** Applies hunks in either direction, preserving unchanged text and line endings. */ +function applyHunks(file: IFileDiff, content: string, reverse: boolean): string { + const lines = contentLines(content); + const result: string[] = []; + let cursor = 0; + let targetLineCount = 0; + for (const hunk of file.hunks) { + const source = reverse ? hunk.after : hunk.before; + const target = reverse ? hunk.before : hunk.after; + const sourceStart = reverse ? hunk.afterStart : hunk.beforeStart; + const targetStart = reverse ? hunk.beforeStart : hunk.afterStart; + if (sourceStart < cursor || sourceStart > lines.length + || lines.slice(sourceStart, sourceStart + source.length).join('') !== source.join('') + || targetLineCount + sourceStart - cursor !== targetStart) { + throw new Error('Codex turn diff does not match the file'); + } + result.push(lines.slice(cursor, sourceStart).join(''), target.join('')); + targetLineCount += sourceStart - cursor + target.length; + cursor = sourceStart + source.length; + } + result.push(lines.slice(cursor).join('')); + return result.join(''); +} + +/** Reconstructs related native turns together, then persists one chronological delta per host checkpoint and path. */ +export async function storeCodexTurnDiffs(captures: readonly ICodexTurnDiffCapture[], context: { + readonly relatedDiffs: readonly string[]; + readonly cwd: URI; + readonly nativeCwd: URI; + readonly database: ISessionDatabase; + readonly fileService: IFileService; + readonly logService: ILogService; +}): Promise { + const parsed = captures.map(capture => ({ + initialFiles: capture.initialDiffs.flatMap(diff => parseTurnDiff(diff, context.cwd)), + snapshots: capture.snapshots.map(snapshot => ({ + ...snapshot, + fileSequences: new Map([...snapshot.fileSequences].map(([path, sequence]) => [resolve(context.cwd.fsPath, relative(context.nativeCwd.fsPath, resolve(context.nativeCwd.fsPath, path))), sequence])), + files: parseTurnDiff(snapshot.diff, context.cwd), + })), + })); + const files = [...parsed.flatMap(capture => [...capture.initialFiles, ...capture.snapshots.flatMap(snapshot => snapshot.files)]), + ...context.relatedDiffs.flatMap(diff => parseTurnDiff(diff, context.cwd))]; + const paths = new Set(files.flatMap(file => [file.beforePath, file.afterPath].filter(path => path !== undefined))); + const contents = new Map(); + const key = (path: string, hash: string) => `${path}\0${hash}`; + const warn = (path: string, error: unknown) => context.logService.warn(`[Codex] Failed to capture turn diff for ${path}`, error); + await Promise.all([...paths].map(async path => { + try { + const content = (await context.fileService.readFile(URI.file(path))).value.toString(); + contents.set(key(path, blobHash(content)), content); + } catch (error) { + if (toFileOperationResult(error) !== FileOperationResult.FILE_NOT_FOUND) { + warn(path, error); + } + } + })); + // A child can replace the parent's after-content before it is stored. Its reverse + // diff supplies that intermediate content, so both originals remain recoverable. + const valid = new Set(); + let pending = files; + while (pending.length) { + const unresolved: IFileDiff[] = []; + for (const file of pending) { + const after = file.afterPath ? contents.get(key(file.afterPath, file.afterHash)) : ''; + const before = file.beforePath ? contents.get(key(file.beforePath, file.beforeHash)) : ''; + const source = after ?? before; + if (source === undefined) { + unresolved.push(file); + continue; + } + try { + const reverse = after !== undefined; + const target = applyHunks(file, source, reverse); + const targetPath = reverse ? file.beforePath : file.afterPath; + const targetHash = reverse ? file.beforeHash : file.afterHash; + if (targetPath ? blobHash(target) !== targetHash : target !== '') { + throw new Error('Codex turn diff content hashes do not match'); + } + if (targetPath) { + contents.set(key(targetPath, targetHash), target); + } + valid.add(file); + } catch (error) { + warn(file.afterPath ?? file.beforePath!, error); + } + } + if (unresolved.length === pending.length) { + break; + } + pending = unresolved; + } + + const unavailable = Symbol('unavailable'); + type Content = string | undefined | typeof unavailable; + interface IEndpoint { readonly content: Content; readonly sequence: number } + const read = (path: string, hash: string): Content => contents.get(key(path, hash)) ?? unavailable; + const hosts = new Map }>(); + for (const capture of parsed) { + const original = new Map(); + for (const file of capture.initialFiles) { + if (file.beforePath && !original.has(file.beforePath)) { + original.set(file.beforePath, read(file.beforePath, file.beforeHash)); + } + if (file.afterPath && !original.has(file.afterPath)) { + original.set(file.afterPath, undefined); + } + } + let previous = original; + for (const snapshot of capture.snapshots) { + const current = new Map(original); + for (const file of snapshot.files) { + if (file.beforePath !== file.afterPath && file.beforePath) { + current.set(file.beforePath, valid.has(file) ? undefined : unavailable); + } + if (file.afterPath) { + current.set(file.afterPath, valid.has(file) ? read(file.afterPath, file.afterHash) : unavailable); + } + } + let host = hosts.get(snapshot.turnId); + if (!host) { + host = { start: snapshot.startSequence, files: new Map() }; + hosts.set(snapshot.turnId, host); + } + host.start = Math.min(host.start, snapshot.startSequence); + for (const [path, after] of current) { + const editSequence = snapshot.fileSequences.get(path); + if (previous.get(path) === after && (!editSequence || editSequence.last < snapshot.startSequence)) { + continue; + } + const before: IEndpoint = { content: previous.get(path), sequence: Math.max(snapshot.startSequence, editSequence?.first ?? snapshot.startSequence) }; + const last: IEndpoint = { content: after, sequence: editSequence?.last ?? snapshot.sequence }; + const existing = host.files.get(path); + host.files.set(path, { + before: existing && existing.before.sequence < before.sequence ? existing.before : before, + after: existing && existing.after.sequence > last.sequence ? existing.after : last, + }); + } + previous = current; + } + } + const previous = new Map(); + for (const [turnId, host] of [...hosts].sort((a, b) => a[1].start - b[1].start)) { + for (const [path, endpoints] of host.files) { + const prior = previous.get(path); + const before = prior ? prior.content : endpoints.before.content; + const last = prior && prior.sequence > endpoints.after.sequence ? prior : endpoints.after; + const after = last.content; + previous.set(path, last); + if (before === unavailable || after === unavailable) { + warn(path, new Error('Codex turn diff content is unavailable')); + continue; + } + if (before === after) { + continue; + } + const content: IFileEditContent = { + beforeContent: before === undefined ? undefined : VSBuffer.fromString(before).buffer, + afterContent: after === undefined ? undefined : VSBuffer.fromString(after).buffer, + }; + await context.database.storeFileEdit({ + turnId, toolCallId: `codex-turn-diff:${turnId}`, filePath: path, + kind: before === undefined ? FileEditKind.Create : after === undefined ? FileEditKind.Delete : FileEditKind.Edit, + ...content, addedLines: undefined, removedLines: undefined, + }); + } + } +} diff --git a/src/vs/platform/agentHost/node/codex/codexTurnDiffTracker.ts b/src/vs/platform/agentHost/node/codex/codexTurnDiffTracker.ts new file mode 100644 index 00000000000000..9741cd85cb8d85 --- /dev/null +++ b/src/vs/platform/agentHost/node/codex/codexTurnDiffTracker.ts @@ -0,0 +1,253 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DeferredPromise } from '../../../../base/common/async.js'; +import type { IDisposable } from '../../../../base/common/lifecycle.js'; +import type { ICodexTurnDiffCapture, ICodexTurnDiffSnapshot } from './codexTurnDiff.js'; +import type { TurnDiffUpdatedNotification } from './protocol/generated/v2/TurnDiffUpdatedNotification.js'; + +interface INativeTurn { + readonly ready: DeferredPromise; + readonly snapshots: ICodexTurnDiffSnapshot[]; + readonly initialDiffs: Map; + readonly fileSequences: Map; + notification: TurnDiffUpdatedNotification; + hostTurnId: string; + startSequence: number; + sequence: number; + status: 'active' | 'complete' | 'storing' | 'failed'; +} + +/** Groups native parents and children by their host checkpoints before reconstructing shared file contents. */ +export class CodexTurnDiffTracker implements IDisposable { + private readonly _nativeTurns = new Map(); + private readonly _pendingChildren = new Map(); + private readonly _hostTurns = new Map>(); + private _sequence = 0; + + constructor(private readonly _store: (captures: readonly ICodexTurnDiffCapture[], relatedDiffs: readonly string[]) => Promise) { } + + private _createTurn(threadId: string, nativeTurnId: string, hostTurnId: string): INativeTurn { + const turn: INativeTurn = { + ready: new DeferredPromise(), snapshots: [], initialDiffs: new Map(), fileSequences: new Map(), hostTurnId, + notification: { threadId, turnId: nativeTurnId, diff: '' }, + startSequence: ++this._sequence, sequence: this._sequence, status: 'active', + }; + void turn.ready.p.catch(() => { }); + this._addHostTurn(hostTurnId, turn); + return turn; + } + + trackChild(threadId: string, hostTurnId: string): void { + if ([...this._hostTurns.get(hostTurnId) ?? []].some(turn => turn.status === 'failed')) { + return; + } + this._pendingChildren.set(threadId, this._createTurn(threadId, '', hostTurnId)); + } + + start(threadId: string, nativeTurnId: string, hostTurnId: string): void { + if (this._nativeTurns.has(nativeTurnId)) { + return; + } + if ([...this._hostTurns.get(hostTurnId) ?? []].some(turn => turn.status === 'failed')) { + return; + } + const turn = this._pendingChildren.get(threadId) ?? this._createTurn(threadId, nativeTurnId, hostTurnId); + this._pendingChildren.delete(threadId); + turn.notification = { threadId, turnId: nativeTurnId, diff: '' }; + this._nativeTurns.set(nativeTurnId, turn); + } + + update(notification: TurnDiffUpdatedNotification): void { + const child = this._pendingChildren.get(notification.threadId); + if (child) { + this.start(notification.threadId, notification.turnId, child.hostTurnId); + } + const turn = this._nativeTurns.get(notification.turnId); + if (!turn || turn.status !== 'active') { + return; + } + turn.notification = notification; + turn.sequence = ++this._sequence; + // Retain each path's first diff even if it later vanishes from a cumulative revert. + for (const section of notification.diff.split(/(?=^diff --git )/m).filter(section => section.length > 0)) { + const header = section.split('\n', 1)[0]; + if (!turn.initialDiffs.has(header)) { + turn.initialDiffs.set(header, section); + } + } + } + + /** File-change completion identifies which cumulative paths actually changed in this host segment. */ + markEdited(nativeTurnId: string, paths: readonly string[]): void { + const turn = this._nativeTurns.get(nativeTurnId); + if (turn?.status === 'active') { + const sequence = ++this._sequence; + turn.sequence = sequence; + for (const path of paths) { + turn.fileSequences.set(path, { first: turn.fileSequences.get(path)?.first ?? sequence, last: sequence }); + } + } + } + + split(nativeTurnId: string, hostTurnId: string): void { + const turn = this._nativeTurns.get(nativeTurnId); + if (turn) { + if (turn.status !== 'failed') { + this._snapshot(turn); + } + turn.hostTurnId = hostTurnId; + turn.startSequence = ++this._sequence; + this._addHostTurn(hostTurnId, turn); + } + } + + private _snapshot(turn: INativeTurn): void { + turn.snapshots.push({ turnId: turn.hostTurnId, diff: turn.notification.diff, startSequence: turn.startSequence, sequence: turn.sequence, fileSequences: new Map(turn.fileSequences) }); + } + + private _addHostTurn(hostTurnId: string, turn: INativeTurn): void { + let turns = this._hostTurns.get(hostTurnId); + if (!turns) { + turns = new Set(); + this._hostTurns.set(hostTurnId, turns); + } + turns.add(turn); + } + + async complete(nativeTurnId: string): Promise { + const turn = this._nativeTurns.get(nativeTurnId); + if (!turn) { + return; + } + if (turn.status === 'active') { + this._snapshot(turn); + turn.status = 'complete'; + void this._flushGroup(turn); + } + await turn.ready.p; + } + + /** Native completion is waiting for a child, rather than only for database persistence. */ + isWaitingForRelatedTurns(nativeTurnId: string): boolean { + return this._nativeTurns.get(nativeTurnId)?.status === 'complete'; + } + + private _relatedTurns(turn: INativeTurn): Set { + const group = new Set([turn]); + for (const member of group) { + for (const host of [member.hostTurnId, ...member.snapshots.map(snapshot => snapshot.turnId)]) { + for (const related of this._hostTurns.get(host) ?? []) { + group.add(related); + } + } + } + return group; + } + + private async _flushGroup(turn: INativeTurn): Promise { + const group = this._relatedTurns(turn); + if ([...group].some(member => member.status !== 'complete')) { + return; + } + const start = (member: INativeTurn) => member.snapshots[0]?.startSequence ?? member.startSequence; + const groupStart = Math.min(...[...group].map(start)); + // Database row order defines the session delta. A replacement turn must not + // overtake the cancelled turn's child snapshots while they are still pending. + if ([...this._nativeTurns.values()].some(member => !group.has(member) && member.status !== 'failed' && start(member) < groupStart)) { + return; + } + const captures = [...group].map(member => ({ notification: member.notification, snapshots: member.snapshots, initialDiffs: [...member.initialDiffs.values()] })); + const relatedDiffs = [...this._nativeTurns.values()].filter(member => !group.has(member) && member.status !== 'failed') + .flatMap(member => [...member.initialDiffs.values(), ...member.snapshots.map(snapshot => snapshot.diff), member.notification.diff]); + for (const member of group) { + member.status = 'storing'; + } + try { + await this._store(captures, relatedDiffs); + for (const member of group) { + this._nativeTurns.delete(member.notification.turnId); + for (const [id, pending] of this._hostTurns) { + pending.delete(member); + if (pending.size === 0) { + this._hostTurns.delete(id); + } + } + member.ready.complete(); + } + } catch (error) { + for (const member of group) { + member.status = 'failed'; + member.ready.error(error); + } + } + this._flushNextGroup(); + } + + private _flushNextGroup(): void { + const start = (turn: INativeTurn) => turn.snapshots[0]?.startSequence ?? turn.startSequence; + const next = [...this._nativeTurns.values()].filter(member => member.status === 'complete').sort((a, b) => start(a) - start(b))[0]; + if (next) { + void this._flushGroup(next); + } + } + + /** Unwinds a child even when no turn/started reached the host before disconnection. */ + async completeThread(threadId: string): Promise { + const pending = this._pendingChildren.get(threadId); + if (pending) { + this._pendingChildren.delete(threadId); + const related = this._hostTurns.get(pending.hostTurnId); + related?.delete(pending); + if (related?.size === 0) { + this._hostTurns.delete(pending.hostTurnId); + } + pending.ready.complete(); + this._flushNextGroup(); + } + await Promise.all([...this._nativeTurns.values()] + .filter(turn => turn.notification.threadId === threadId && turn.status !== 'failed') + .map(turn => this.complete(turn.notification.turnId))); + } + + /** A stuck child must fail its checkpoint instead of holding all future persistence behind it. */ + failWaitingTurn(hostTurnId: string, error: Error): void { + const turn = this._hostTurns.get(hostTurnId)?.values().next().value; + if (!turn) { + return; + } + const group = this._relatedTurns(turn); + if ([...group].some(member => member.status === 'storing')) { + return; + } + for (const member of group) { + member.status = 'failed'; + member.initialDiffs.clear(); + member.ready.error(error); + if (this._pendingChildren.get(member.notification.threadId) === member) { + this._pendingChildren.delete(member.notification.threadId); + } + } + this._flushNextGroup(); + } + + async waitForTurnEdits(hostTurnId: string): Promise { + let turns: Set | undefined; + while ((turns = this._hostTurns.get(hostTurnId))?.size) { + await Promise.all([...turns].map(turn => turn.ready.p)); + } + } + + dispose(): void { + for (const turns of this._hostTurns.values()) { + for (const turn of turns) { + turn.ready.cancel(); + } + } + this._hostTurns.clear(); + this._nativeTurns.clear(); + this._pendingChildren.clear(); + } +} diff --git a/src/vs/platform/agentHost/node/compatibleSessionDatabase.ts b/src/vs/platform/agentHost/node/compatibleSessionDatabase.ts index 8a10d5c7d9cd8d..a0682deec01162 100644 --- a/src/vs/platform/agentHost/node/compatibleSessionDatabase.ts +++ b/src/vs/platform/agentHost/node/compatibleSessionDatabase.ts @@ -132,6 +132,10 @@ export class CompatibleSessionDatabase implements ISessionDatabase { return result; } + async deleteProviderTurns(...[providerTurnIds, afterHostTurnId]: Parameters): ReturnType { + return this.raw.deleteProviderTurns(providerTurnIds, afterHostTurnId); + } + async insertPersistedTurn(...[record]: Parameters): ReturnType { const result = await this.raw.insertPersistedTurn({ ...record, chatUri: this.identity.toStorage(record.chatUri), payload: this.identity.turnPayload(record.payload, 'storage') }); return result; diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 3cc260864fb992..a38f8f2ed4a84b 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -108,11 +108,12 @@ import { ILocalModelSetupService } from './localModelBridge/localModelSetupServi import { readLocalModelRootConfig, selectLocalModelProvider, type ILocalModelProvider } from './localModelBridge/localModelProvider.js'; import { getLocalModelThinkingLevels, type LocalModelThinkingSupport } from './localModelBridge/localModelThinking.js'; import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitProject.js'; -import { parsedPluginsEqual, toChildCustomizations } from './copilotPluginConverters.js'; +import { findRelocatedAgentName, parsedPluginsEqual, toChildCustomizations } from './copilotPluginConverters.js'; import { CopilotGitHubTelemetryForwarder, type ICopilotModelCallCorrelationTelemetry } from './copilotGitHubTelemetryForwarder.js'; import { CopilotGitHubCredentials, CopilotGitHubSessionCredentials } from './copilotGitHubCredentials.js'; import { CopilotSecondaryAssignmentContext } from './copilotSecondaryAssignmentContext.js'; import { CopilotSessionLauncher, AutoTierConfigKey, ContextSizeConfigKey, CopilotAutoTierConfigKey, CopilotReasoningEffortConfigKey, ThinkingLevelConfigKey, getCopilotContextTier, getLocalModelSelectionId, isCopilotReasoningEffort, isCopilotSessionTelemetryEnabled, resolveCopilotAutoTier, resolveCopilotReasoningEffort, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from './copilotSessionLauncher.js'; +import { CopilotProjectExtensionLaunchPolicy, parseCopilotProjectExtensionTrustGrant, serializeCopilotProjectExtensionTrustGrant, type ICopilotProjectExtensionTrustGrant } from './copilotProjectExtensionLaunchPolicy.js'; import { createLocalModelRowMeta } from '../../common/meta/localModelStatus.js'; import { errorCodeForLog } from '../../common/logRedaction.js'; import { withSessionInitiator } from '../../common/meta/agentSessionInitiatorMeta.js'; @@ -121,6 +122,7 @@ import { ShellManager } from './copilotShellTools.js'; import { isAgentHostTelemetryService } from '../agentHostTelemetryService.js'; import { ICopilotApiService, type IRestrictedTelemetryContext } from '../shared/copilotApiService.js'; import { captureCopilotTelemetryContext } from '../shared/copilotSkuTelemetry.js'; +import { readAgentEntitlementHint } from '../../common/meta/agentEntitlementHintMeta.js'; import { AgentHostGitHubTelemetryRouter } from '../agentHostGitHubTelemetryRouter.js'; import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; import { CopilotSlashCommandCompletionProvider, ICopilotRuntimeSlashCommandQueryOptions } from './copilotSlashCommandCompletionProvider.js'; @@ -367,6 +369,7 @@ interface ICopilotProvisionalLaunch { /** The session that owned the chat at launch, for owner-level state such as the baseline checkpoint. */ readonly owner: URI; readonly project: Promise; + readonly projectExtensionTrustGrant: ICopilotProjectExtensionTrustGrant | undefined; } /** @@ -480,6 +483,7 @@ interface IWorkingDirectoryMetadataSnapshot { readonly projectUri?: string; readonly projectDisplayName?: string; readonly projectResolved?: string; + readonly projectExtensionTrustGrant?: string; } /** @@ -493,6 +497,21 @@ interface ICopilotMetadataScope { readonly owner: URI; } +interface ICopilotSessionMetadataUpdate { + readonly model?: ModelSelection; + readonly workingDirectory?: URI; + readonly workingDirectories?: readonly URI[]; + readonly customizationDirectory?: URI; + readonly project?: IAgentSessionProjectInfo; + readonly projectResolved?: boolean; + readonly configValues?: Record; + readonly archived?: boolean; + readonly ehcliAdopted?: boolean; + readonly lastMigratedTurnId?: string; + /** `null` clears an existing persisted grant; `undefined` leaves it unchanged. */ + readonly projectExtensionTrustGrant?: ICopilotProjectExtensionTrustGrant | null; +} + /** The metadata scope of `session` itself: its own state. */ function sessionMetadataScope(session: URI): ICopilotMetadataScope { return { storage: session, owner: session }; @@ -1093,12 +1112,6 @@ export class CopilotAgent extends Disposable implements IAgent { private readonly _chatBackings = new Map(); /** Canvases a configuration refresh is handing from a chat's previous runtime to its replacement. */ private readonly _canvasHandoffs = new ResourceMap(); - /** - * Canonical filesystem roots with active project-extension launch admission. - * The value is the number of live admissions for the root, so disposing one - * session cannot revoke a root that another session still admits. - */ - private readonly _extensionLaunchRoots = new Map(); private readonly _workingDirectoryMutations = new ResourceMap(); /** Exact chat -> its owner as the host context recorded it (read at use), for fork/restore paths that only know the chat URI. */ @@ -1181,6 +1194,7 @@ export class CopilotAgent extends Disposable implements IAgent { private _isShuttingDown = false; private readonly _plugins: PluginController; private readonly _sessionLauncher: CopilotSessionLauncher; + private readonly _projectExtensionLaunchPolicy: CopilotProjectExtensionLaunchPolicy; private readonly _gitHubTelemetryForwarder: CopilotGitHubTelemetryForwarder; private readonly _secondaryAssignmentContext: CopilotSecondaryAssignmentContext; private readonly _githubTelemetryRouter: AgentHostGitHubTelemetryRouter | undefined; @@ -1256,6 +1270,7 @@ export class CopilotAgent extends Disposable implements IAgent { this._sessionTelemetryRevoked = !isCopilotSessionTelemetryEnabled(this._configurationService, this._telemetryService); this._plugins = this._register(this._instantiationService.createInstance(PluginController, () => this._ensureClient())); this._sessionLauncher = this._instantiationService.createInstance(CopilotSessionLauncher); + this._projectExtensionLaunchPolicy = new CopilotProjectExtensionLaunchPolicy(this._configurationService, this._logService); this._sandboxCertificate = this._instantiationService.createInstance(CopilotSandboxCertificate); this._customizationInstallations = this._register(new CopilotCustomizationInstallations( () => this._ensureClient(), @@ -2146,59 +2161,8 @@ export class CopilotAgent extends Disposable implements IAgent { return new CopilotClient(options); } - private async _acquireExtensionLaunchAdmission(directories: readonly URI[]): Promise { - const roots: string[] = []; - for (const directory of directories) { - if (directory.scheme !== Schemas.file) { - continue; - } - try { - roots.push(await fs.realpath(directory.fsPath)); - } catch (error) { - this._logService.warn(`[Copilot] Failed to canonicalize extension launch root '${directory.fsPath}': ${getErrorMessage(error)}`); - } - } - for (const root of roots) { - this._extensionLaunchRoots.set(root, (this._extensionLaunchRoots.get(root) ?? 0) + 1); - } - return toDisposable(() => { - for (const root of roots) { - const count = this._extensionLaunchRoots.get(root); - if (count === undefined || count <= 1) { - this._extensionLaunchRoots.delete(root); - } else { - this._extensionLaunchRoots.set(root, count - 1); - } - } - }); - } - - private async _resolveExtensionLaunch(modulePath: string, source: 'project' | 'user' | 'plugin' | 'session', extensionBootstrapPath: string) { - if (!isAbsolute(modulePath) || resourceBasename(URI.file(modulePath)) !== 'extension.mjs' || source === 'session') { - return {}; - } - if (source === 'project') { - let canonicalModulePath: string; - try { - canonicalModulePath = await fs.realpath(modulePath); - } catch { - return {}; - } - if (resourceBasename(URI.file(canonicalModulePath)) !== 'extension.mjs' - || ![...this._extensionLaunchRoots.keys()].some(root => isEqualOrParent(URI.file(canonicalModulePath), URI.file(root)))) { - return {}; - } - } - return { - launch: { - executable: process.execPath, - args: [extensionBootstrapPath], - env: { - EXTENSION_PATH: modulePath, - ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: '1' } : {}), - }, - }, - }; + protected _acquireProjectExtensionLaunchAdmission(session: CopilotAgentSession): Promise { + return this._projectExtensionLaunchPolicy.acquireAdmission(session.extensionLaunchDirectories, session.projectExtensionTrustGrant); } // ---- auth --------------------------------------------------------------- @@ -2479,7 +2443,7 @@ export class CopilotAgent extends Disposable implements IAgent { } // 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(siblingScope)).workingDirectories) { - await this._storeSessionMetadata(siblingScope, undefined, workingDirectory, [workingDirectory], undefined, undefined); + await this._storeSessionMetadata(siblingScope, { workingDirectory, workingDirectories: [workingDirectory] }); } } } @@ -2504,6 +2468,7 @@ export class CopilotAgent extends Disposable implements IAgent { workingDirectory: directory.toString(), workingDirectories: JSON.stringify([directory.toString()]), customizationDirectory: directory.toString(), + projectExtensionTrustGrant: '', sharedCustomizationDirectories: options.sharedCustomizationDirectories ? JSON.stringify(options.sharedCustomizationDirectories.map(directory => directory.toString())) : undefined, @@ -2998,7 +2963,7 @@ export class CopilotAgent extends Disposable implements IAgent { return selectedModel !== undefined && this._byokModels.includes(selectedModel); } - async authenticate(resource: string, token: string, expiresIn?: number): Promise { + async authenticate(resource: string, token: string, expiresIn?: number, _meta?: Record): Promise { if (resource === this._gitHubEndpointService.getRepoResource().resource) { return true; } @@ -3020,8 +2985,19 @@ export class CopilotAgent extends Disposable implements IAgent { this._authenticationRequired.set(undefined, undefined); } await this._applyGitHubToken(token || undefined, expiresIn); + if (endpointGeneration !== this._gitHubEndpointGeneration || this._githubCredentials.token !== (token || undefined)) { + return; + } this._automationAuthenticationReady.set(token.length > 0, undefined); }); + const entitlement = readAgentEntitlementHint({ _meta }); + if (token && entitlement && endpointGeneration === this._gitHubEndpointGeneration && this._githubCredentials.token === token) { + void this._copilotApiService.refreshCopilotUser?.(token, entitlement.trackingId, entitlement.sku).then(async () => { + if (endpointGeneration === this._gitHubEndpointGeneration && this._githubCredentials.token === token) { + await this._resolveRestrictedTelemetry(token); + } + }).catch(error => this._logService.warn('[Copilot] Failed to refresh account discovery after an entitlement change', error)); + } return true; } @@ -3868,7 +3844,7 @@ export class CopilotAgent extends Disposable implements IAgent { enableRemoteSessions: scoped?.kind !== 'storage' && startupConfig.sessionSync, ...(extensionBootstrapPath && extensionSdkPath ? { extensionLaunchProvider: { - resolve: request => this._resolveExtensionLaunch(request.modulePath, request.source, extensionBootstrapPath), + resolve: request => this._projectExtensionLaunchPolicy.resolveLaunch(request.modulePath, request.source, extensionBootstrapPath), }, } : {}), onGetTraceContext: () => this._otelService.getCurrentTraceContext() ?? {}, @@ -5448,7 +5424,13 @@ export class CopilotAgent extends Disposable implements IAgent { // Persist metadata before resume so `_resumeSession` can resolve the // working directory and model. const project = await projectPromise; - await this._storeSessionMetadata(sessionMetadataScope(sessionUri), model, workingDirectory, options.workingDirectories ?? ([workingDirectory]), workingDirectory, project); + await this._storeSessionMetadata(sessionMetadataScope(sessionUri), { + model, + workingDirectory, + workingDirectories: options.workingDirectories ?? [workingDirectory], + customizationDirectory: workingDirectory, + project, + }); if (options.agent !== undefined) { await this._storeSessionAgentMetadata(sessionUri, options.agent); } @@ -5890,7 +5872,17 @@ export class CopilotAgent extends Disposable implements IAgent { // chat editor can substitute the session-wide changeset for that migrated // (checkpoint-less) turn without misattributing it to a later, post-adoption turn. const lastMigratedTurnId = await this._readExtensionHostCliLastTurnId(sessionId); - await this._storeSessionMetadata(sessionMetadataScope(session), undefined, workingDirectory, [workingDirectory], workingDirectory, project, workspacelessFallback || project !== undefined, { [SessionConfigKey.Isolation]: 'folder' }, archived, /* ehcliAdopted */ true, lastMigratedTurnId); + await this._storeSessionMetadata(sessionMetadataScope(session), { + workingDirectory, + workingDirectories: [workingDirectory], + customizationDirectory: workingDirectory, + project, + projectResolved: workspacelessFallback || project !== undefined, + configValues: { [SessionConfigKey.Isolation]: 'folder' }, + archived, + ehcliAdopted: true, + lastMigratedTurnId, + }); const titleSource: 'user' | 'auto' = adoptedTitle === customTitle ? 'user' : AGENT_HOST_TITLE_SOURCE_AUTO; const listVisible: IAgentChatAdoptionResult['listVisible'] = adoptedTitle !== undefined ? { title: adoptedTitle, titleSource, isRead: true } @@ -6109,6 +6101,12 @@ export class CopilotAgent extends Disposable implements IAgent { const snapshot = await activeClient.snapshot(chat.toString(), anchor?.object, provisional.agent?.uri); const shellManager = this._instantiationService.createInstance(ShellManager, owner, chat, storage, workingDirectory); const resolvedAgent = provisional.isEphemeral ? undefined : await this._resolveAgentWhenMaterializing(provisional, snapshot, workingDirectory); + const projectExtensionTrustGrant = this._configurationService.getSessionConfigValues(owner.toString())?.[SessionConfigKey.Isolation] === 'worktree' + ? await this._projectExtensionLaunchPolicy.resolveTrustGrant(workingDirectory, { + kind: 'hostCreatedWorktree', + repositoryRoot: provisional.project?.uri, + }) + : undefined; const launchPlan: CopilotSessionLaunchPlan = { kind: 'create', client, @@ -6118,6 +6116,7 @@ export class CopilotAgent extends Disposable implements IAgent { hasScopedEditSurface: provisional.hasScopedEditSurface, workingDirectory, additionalDirectories: this._additionalCustomizationDirectories(resolvedWorkingDirectories), + projectExtensionTrustGrant, resolvedAgentName: resolvedAgent?.name, snapshot, disabledRootMcpServers: await this._disabledRootMcpServers(owner, snapshot), @@ -6144,7 +6143,7 @@ export class CopilotAgent extends Disposable implements IAgent { await this._initializeAndRegisterSession(initializingSession, () => { this._registerInitializedSession(sdkSessionId, initializingSession, activeClient, launchPlan.client); }, undefined, token); - return { session: agentSession, agent: resolvedAgent?.agent, workingDirectory, customizationDirectory, storage, owner, project }; + return { session: agentSession, agent: resolvedAgent?.agent, workingDirectory, customizationDirectory, storage, owner, project, projectExtensionTrustGrant }; } catch (error) { agentSession?.dispose(); throw error; @@ -6160,7 +6159,7 @@ export class CopilotAgent extends Disposable implements IAgent { */ private async _completeProvisionalMaterialization(provisional: IProvisionalSession, launch: ICopilotProvisionalLaunch, resolvedWorkingDirectories: readonly URI[] | undefined, stageRecorder: IAgentProviderSendStageRecorder | undefined): Promise { const { sessionId, sdkSessionId, chat } = provisional; - const { session: agentSession, agent, workingDirectory, customizationDirectory, storage, owner } = launch; + const { session: agentSession, agent, workingDirectory, customizationDirectory, storage, owner, projectExtensionTrustGrant } = launch; stageRecorder?.mark('persist'); const project = await launch.project; @@ -6170,7 +6169,15 @@ export class CopilotAgent extends Disposable implements IAgent { const materializedWorkingDirectories = resolvedWorkingDirectories ?? ([workingDirectory]); this._provisionalSessions.delete(sessionId); - await this._storeSessionMetadata({ storage, owner }, provisional.model, workingDirectory, materializedWorkingDirectories, customizationDirectory, project, true); + await this._storeSessionMetadata({ storage, owner }, { + model: provisional.model, + workingDirectory, + workingDirectories: materializedWorkingDirectories, + customizationDirectory, + project, + projectResolved: true, + projectExtensionTrustGrant: projectExtensionTrustGrant ?? null, + }); await deferCopilotSdkExecution(this._sessionDataService, storage, sdkSessionId, this._logService); if (agent !== undefined) { await this._storeSessionAgentMetadata(storage, agent); @@ -7175,6 +7182,11 @@ export class CopilotAgent extends Disposable implements IAgent { const sourceMetadata = inheritsFromOtherSession ? await this._readSessionMetadata(sessionMetadataScope(forkSourceScope!)) : undefined; const model = options.model ?? sourceMetadata?.model; const agent = options.agent ?? sourceMetadata?.agent; + const additionalWorktree = context.sessionSettings?.additionalWorktrees.find(worktree => isEqual(URI.parse(worktree.workingDirectory), workingDirectory)); + const additionalWorktreeRepository = additionalWorktree ? URI.parse(additionalWorktree.repositoryRoot) : undefined; + const launchProject = additionalWorktreeRepository + ? { uri: additionalWorktreeRepository, displayName: resourceBasename(additionalWorktreeRepository) } + : options.project ?? sourceMetadata?.project; const client = await this._ensureClientForChat(chat); const chatSdkId = generateUuid(); // Chat backings share the owning session's ActiveClient so that @@ -7199,6 +7211,19 @@ export class CopilotAgent extends Disposable implements IAgent { const storageScope = context.resource; // The host-owned flag resume reads too, so this chat's system prompt matches its parent's and survives resume. const workspaceless = this._sessionSettings.get(session)?.workspaceless; + const hasWorktreeProvenance = isEqual(storageScope, session) || additionalWorktree !== undefined; + const projectExtensionTrustGrant = hasWorktreeProvenance && launchProject?.uri + ? await this._projectExtensionLaunchPolicy.resolveTrustGrant(workingDirectory, { + kind: 'knownRepository', + repositoryRoot: launchProject.uri, + }) + : await this._projectExtensionLaunchPolicy.resolvePersistedTrustGrant( + workingDirectory, + (await this._readSessionMetadata(sessionMetadataScope(session))).projectExtensionTrustGrant, + ); + const effectiveProject = projectExtensionTrustGrant + ? { uri: projectExtensionTrustGrant.repositoryRoot, displayName: resourceBasename(projectExtensionTrustGrant.repositoryRoot) } + : launchProject; // Forking: mint the new chat's backing by forking the source chat's // SDK conversation at the requested turn (copying its database into @@ -7223,6 +7248,7 @@ export class CopilotAgent extends Disposable implements IAgent { extensionSdkPath: this._extensionSdkPathByClient.get(client), sessionId: sdkSessionId, workingDirectory, + projectExtensionTrustGrant, resolvedAgentName: undefined, snapshot, disabledRootMcpServers: await this._disabledRootMcpServers(session, snapshot), @@ -7241,6 +7267,7 @@ export class CopilotAgent extends Disposable implements IAgent { extensionSdkPath: this._extensionSdkPathByClient.get(client), sessionId: chatSdkId, workingDirectory, + projectExtensionTrustGrant, resolvedAgentName: undefined, snapshot, disabledRootMcpServers: await this._disabledRootMcpServers(session, snapshot), @@ -7258,15 +7285,29 @@ export class CopilotAgent extends Disposable implements IAgent { // The inherited history now lives in a session the agent has no // metadata for, so persist what a later resume needs before the // runtime starts. - let project: IAgentSessionProjectInfo | undefined; + let project = effectiveProject; if (inheritsFromOtherSession) { - project = await projectFromCopilotContext({ cwd: workingDirectory.fsPath }, this._gitService); + project ??= await projectFromCopilotContext({ cwd: workingDirectory.fsPath }, this._gitService); inheritedWorkingDirectories = sourceMetadata?.workingDirectories ?? (sourceEntry?.workingDirectory ? [sourceEntry.workingDirectory] : [workingDirectory]); - await this._storeSessionMetadata(sessionMetadataScope(session), model, workingDirectory, inheritedWorkingDirectories, workingDirectory, project); + await this._storeSessionMetadata(sessionMetadataScope(session), { + model, + workingDirectory, + workingDirectories: inheritedWorkingDirectories, + customizationDirectory: workingDirectory, + project, + projectResolved: true, + projectExtensionTrustGrant: projectExtensionTrustGrant ?? null, + }); if (agent !== undefined) { await this._storeSessionAgentMetadata(session, agent); } + } else if (project) { + await this._storeSessionMetadata({ storage: storageScope, owner: session }, { + project, + projectResolved: true, + projectExtensionTrustGrant: projectExtensionTrustGrant ?? null, + }); } let agentSession: CopilotAgentSession | undefined; @@ -7864,6 +7905,11 @@ export class CopilotAgent extends Disposable implements IAgent { const snapshot = await activeClient.snapshot(chatKey, anchor?.object, info.agent?.uri); const disabledRootMcpServers = await this._disabledRootMcpServers(ownerSession, snapshot); const shellManager = this._instantiationService.createInstance(ShellManager, ownerSession, chat, context.resource, workingDirectory); + const projectExtensionTrustGrant = await this._projectExtensionLaunchPolicy.resolvePersistedTrustGrant(workingDirectory, storedMetadata.projectExtensionTrustGrant); + const project = storedMetadata.project; + const effectiveProject = projectExtensionTrustGrant + ? { uri: projectExtensionTrustGrant.repositoryRoot, displayName: resourceBasename(projectExtensionTrustGrant.repositoryRoot) } + : project; const launchPlan: CopilotSessionLaunchPlan = { kind: 'resume', client, @@ -7871,6 +7917,7 @@ export class CopilotAgent extends Disposable implements IAgent { sessionId: info.sdkSessionId, workingDirectory, additionalDirectories, + projectExtensionTrustGrant, resolvedAgentName: info.agent ? this._resolveAgentName(snapshot, info.agent) : undefined, snapshot, disabledRootMcpServers, @@ -7891,7 +7938,14 @@ export class CopilotAgent extends Disposable implements IAgent { stageRecorder?.mark('persist'); // An archived worktree's repository-root fallback is for history, not a workspace change. if (launchWorkingDirectories && isEqual(workingDirectory, persistedWorkingDirectory)) { - await this._storeSessionMetadata({ storage: context.resource, owner: ownerSession }, info.model, workingDirectory, launchWorkingDirectories, undefined, undefined); + await this._storeSessionMetadata({ storage: context.resource, owner: ownerSession }, { + model: info.model, + workingDirectory, + workingDirectories: launchWorkingDirectories, + project: effectiveProject, + projectResolved: effectiveProject !== undefined, + projectExtensionTrustGrant: projectExtensionTrustGrant ?? null, + }); } this._logService.info(`[Copilot] Resumed chat backing ${chatKey} for configuration ${ownerSession.toString()}`); return agentSession; @@ -8023,7 +8077,7 @@ export class CopilotAgent extends Disposable implements IAgent { const context = this._resolveChatContext(chat, operationContext); await this._queueChatTurn(context, 'changeModel', undefined, async token => { await raceCancellationError(this._validateModelSelection(model), token); - const current = this._resolveChatContext(chat, operationContext); + let current = this._resolveChatContext(chat, operationContext); const longContextWindow = this._longContextWindowFor(model.id); const freeLongContext = this._isFreeLongContext(model.id); // A `family` alias routes the host's prompt and tool profile only. The @@ -8032,6 +8086,8 @@ export class CopilotAgent extends Disposable implements IAgent { if (provisional) { provisional.model = model; } else { + await this._applyChatCredentialBoundary(current, this._captureChatCredential(current)); + current = this._resolveChatContext(chat, operationContext); const entry = current.target ?? await this._ensureResolvedChatSession(current, undefined, undefined, token); const previousModelId = this._chatBackings.get(current.chatKey)?.model?.id; const policy = entry @@ -8050,7 +8106,7 @@ export class CopilotAgent extends Disposable implements IAgent { // Keep the session-scope metadata in step for resumes that fall back // to it; chat leaves persist through their backing instead. if (current.resource.toString() === current.ownerSession.toString()) { - await this._storeSessionMetadata({ storage: current.resource, owner: current.ownerSession }, model, undefined, undefined, undefined, undefined); + await this._storeSessionMetadata({ storage: current.resource, owner: current.ownerSession }, { model }); } } const backing = this._chatBackings.get(current.chatKey); @@ -8076,14 +8132,19 @@ export class CopilotAgent extends Disposable implements IAgent { private async _changeAgentOnce(chat: URI, agent: AgentSelection | undefined, operationContext: URI | IAgentChatContext): Promise { const context = this._resolveChatContext(chat, operationContext); await this._queueChatTurn(context, 'changeAgent', undefined, async token => { - const current = this._resolveChatContext(chat, operationContext); + let current = this._resolveChatContext(chat, operationContext); const provisional = this._provisionalSessions.get(current.provisionalKey); if (provisional) { provisional.agent = agent; } else { + await this._applyChatCredentialBoundary(current, this._captureChatCredential(current)); + current = this._resolveChatContext(chat, operationContext); const entry = current.target ?? await this._ensureResolvedChatSession(current, undefined, undefined, token); if (entry) { - const resolvedAgentName = agent ? this._resolveAgentName(entry.appliedSnapshot, agent) : undefined; + const published = this._activeClients.get(current.ownerSession)?.pluginController.currentAppliedPlugins(); + const resolvedAgentName = agent + ? this._resolveAgentName(entry.appliedSnapshot, agent) ?? (published && findRelocatedAgentName(entry.appliedSnapshot.plugins, published, agent.uri)) + : undefined; await entry.setAgent(resolvedAgentName); } } @@ -8553,7 +8614,7 @@ export class CopilotAgent extends Disposable implements IAgent { session.dispose(); throw new CancellationError(); } - const extensionLaunchAdmission = await this._acquireExtensionLaunchAdmission(session.extensionLaunchDirectories); + const extensionLaunchAdmission = await this._acquireProjectExtensionLaunchAdmission(session); if (this._isShuttingDown || token.isCancellationRequested) { extensionLaunchAdmission.dispose(); session.dispose(); @@ -8670,6 +8731,10 @@ export class CopilotAgent extends Disposable implements IAgent { if (storedMetadata.agent && !resolvedAgentName) { this._logService.info(`[Copilot:${sessionId}] Stored custom agent is not available in the current plugin snapshot; resuming without a custom agent`); } + const projectExtensionTrustGrant = await this._projectExtensionLaunchPolicy.resolvePersistedTrustGrant(resolvedWorkingDirectory, storedMetadata.projectExtensionTrustGrant); + const effectiveProject = projectExtensionTrustGrant + ? { uri: projectExtensionTrustGrant.repositoryRoot, displayName: resourceBasename(projectExtensionTrustGrant.repositoryRoot) } + : storedMetadata.project; const launchPlan: CopilotSessionLaunchPlan = { kind: 'resume', client, @@ -8677,6 +8742,7 @@ export class CopilotAgent extends Disposable implements IAgent { sessionId, workingDirectory: resolvedWorkingDirectory, additionalDirectories, + projectExtensionTrustGrant, resolvedAgentName, snapshot, disabledRootMcpServers, @@ -8698,7 +8764,12 @@ export class CopilotAgent extends Disposable implements IAgent { this._registerInitializedSession(sessionId, agentSession, activeClient, launchPlan.client); }, async () => { stageRecorder?.mark('persist'); - await this._storeSessionMetadata({ storage: sessionUri, owner: owner.get() }, undefined, undefined, launchWorkingDirectories, undefined, undefined); + await this._storeSessionMetadata({ storage: sessionUri, owner: owner.get() }, { + workingDirectories: launchWorkingDirectories, + project: effectiveProject, + projectResolved: effectiveProject !== undefined, + projectExtensionTrustGrant: projectExtensionTrustGrant ?? null, + }); }, token); } catch (err) { agentSession.dispose(); @@ -8740,6 +8811,7 @@ export class CopilotAgent extends Disposable implements IAgent { private static readonly _META_PROJECT_RESOLVED = 'copilot.project.resolved'; private static readonly _META_PROJECT_URI = 'copilot.project.uri'; private static readonly _META_PROJECT_DISPLAY_NAME = 'copilot.project.displayName'; + private static readonly _META_PROJECT_EXTENSION_TRUST_GRANT = 'copilot.projectExtensionTrustGrant'; /** Legacy persisted catalog of concrete chat backings, keyed by chatId. */ private static readonly _META_CHATS = 'copilot.chats'; @@ -8780,7 +8852,20 @@ export class CopilotAgent extends Disposable implements IAgent { /** Persists supplied metadata in one transaction, leaving omitted fields unchanged. */ - private async _storeSessionMetadata(scope: ICopilotMetadataScope, model: ModelSelection | undefined, workingDirectory: URI | undefined, workingDirectories: readonly URI[] | undefined, customizationDirectory: URI | undefined, project: IAgentSessionProjectInfo | undefined, projectResolved = project !== undefined, configValues?: Record, archived?: boolean, ehcliAdopted?: boolean, lastMigratedTurnId?: string): Promise { + private async _storeSessionMetadata(scope: ICopilotMetadataScope, update: ICopilotSessionMetadataUpdate): Promise { + const { + model, + workingDirectory, + workingDirectories, + customizationDirectory, + project, + projectResolved = project !== undefined, + configValues, + archived, + ehcliAdopted, + lastMigratedTurnId, + projectExtensionTrustGrant, + } = update; const metadata: Record = {}; if (model) { metadata[CopilotAgent._META_MODEL] = this._serializeModelSelection(model); @@ -8824,6 +8909,9 @@ export class CopilotAgent extends Disposable implements IAgent { metadata[CopilotAgent._META_PROJECT_URI] = project.uri.toString(); metadata[CopilotAgent._META_PROJECT_DISPLAY_NAME] = project.displayName; } + if (projectExtensionTrustGrant !== undefined) { + metadata[CopilotAgent._META_PROJECT_EXTENSION_TRUST_GRANT] = serializeCopilotProjectExtensionTrustGrant(projectExtensionTrustGrant ?? undefined); + } // Persisted the same way `AgentService._persistConfigValues` writes them, // so restore's config resolution overlays them (used by adopt to force // folder isolation) — folded into this write to avoid a second DB open. @@ -8871,6 +8959,7 @@ export class CopilotAgent extends Disposable implements IAgent { [CopilotAgent._META_PROJECT_URI]: true, [CopilotAgent._META_PROJECT_DISPLAY_NAME]: true, [CopilotAgent._META_PROJECT_RESOLVED]: true, + [CopilotAgent._META_PROJECT_EXTENSION_TRUST_GRANT]: true, }); const workingDirectory = metadata[CopilotAgent._META_CWD]; const customizationDirectory = metadata[CopilotAgent._META_CUSTOMIZATION_DIRECTORY]; @@ -8886,6 +8975,7 @@ export class CopilotAgent extends Disposable implements IAgent { projectUri: metadata[CopilotAgent._META_PROJECT_URI], projectDisplayName: metadata[CopilotAgent._META_PROJECT_DISPLAY_NAME], projectResolved: metadata[CopilotAgent._META_PROJECT_RESOLVED], + projectExtensionTrustGrant: metadata[CopilotAgent._META_PROJECT_EXTENSION_TRUST_GRANT], }, }; } finally { @@ -8902,6 +8992,7 @@ export class CopilotAgent extends Disposable implements IAgent { [CopilotAgent._META_PROJECT_URI]: metadata.projectUri ?? '', [CopilotAgent._META_PROJECT_DISPLAY_NAME]: metadata.projectDisplayName ?? '', [CopilotAgent._META_PROJECT_RESOLVED]: metadata.projectResolved ?? '', + [CopilotAgent._META_PROJECT_EXTENSION_TRUST_GRANT]: metadata.projectExtensionTrustGrant ?? '', }; if (isSessionStateScope(scope)) { // The customization directories and project are session state: central first, then the session database mirror. @@ -8969,7 +9060,7 @@ export class CopilotAgent extends Disposable implements IAgent { } /** Reads chat metadata, preferring the host's workspaceless state over legacy provider storage. */ - private async _readSessionMetadata(scope: ICopilotMetadataScope): Promise<{ model?: ModelSelection; agent?: AgentSelection; workingDirectory?: URI; workingDirectories?: readonly URI[]; customizationDirectory?: URI; workspaceless?: boolean }> { + private async _readSessionMetadata(scope: ICopilotMetadataScope): Promise<{ model?: ModelSelection; agent?: AgentSelection; workingDirectory?: URI; workingDirectories?: readonly URI[]; customizationDirectory?: URI; project?: IAgentSessionProjectInfo; projectExtensionTrustGrant?: ICopilotProjectExtensionTrustGrant; workspaceless?: boolean }> { const workspaceless = this._sessionSettings.get(scope.owner)?.workspaceless; const ref = await this._openMetadata(scope); if (!ref) { @@ -8982,10 +9073,16 @@ export class CopilotAgent extends Disposable implements IAgent { [CopilotAgent._META_CWD]: true, [CopilotAgent._META_CWDS]: true, [CopilotAgent._META_CUSTOMIZATION_DIRECTORY]: true, + [CopilotAgent._META_PROJECT_URI]: true, + [CopilotAgent._META_PROJECT_DISPLAY_NAME]: true, + [CopilotAgent._META_PROJECT_EXTENSION_TRUST_GRANT]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, }); const cwd = m[CopilotAgent._META_CWD]; const customizationDirectory = m[CopilotAgent._META_CUSTOMIZATION_DIRECTORY]; + const projectUri = m[CopilotAgent._META_PROJECT_URI]; + const projectDisplayName = m[CopilotAgent._META_PROJECT_DISPLAY_NAME]; + const projectExtensionTrustGrant = parseCopilotProjectExtensionTrustGrant(m[CopilotAgent._META_PROJECT_EXTENSION_TRUST_GRANT]); const workingDirectory = cwd ? URI.parse(cwd) : undefined; return { model: this._parseModelSelection(m[CopilotAgent._META_MODEL]), @@ -8993,6 +9090,8 @@ export class CopilotAgent extends Disposable implements IAgent { workingDirectory, workingDirectories: this._parseWorkingDirectories(m[CopilotAgent._META_CWDS], workingDirectory), customizationDirectory: customizationDirectory ? URI.parse(customizationDirectory) : undefined, + project: projectUri && projectDisplayName ? { uri: URI.parse(projectUri), displayName: projectDisplayName } : undefined, + projectExtensionTrustGrant, workspaceless: workspaceless ?? m[AH_META_WORKSPACELESS_DB_KEY] === 'true', }; } finally { @@ -9062,7 +9161,7 @@ export class CopilotAgent extends Disposable implements IAgent { } private async _storeSessionProjectResolution(scope: ICopilotMetadataScope, project: IAgentSessionProjectInfo | undefined): Promise { - await this._storeSessionMetadata(scope, undefined, undefined, undefined, undefined, project, true); + await this._storeSessionMetadata(scope, { project, projectResolved: true }); } /** @@ -10438,6 +10537,7 @@ class ActiveClient extends Disposable { pluginController: SessionPluginController, onDidSessionProgress: Emitter, @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, + @IFileService private readonly _fileService: IFileService, ) { super(); this.pluginController = this._register(pluginController); @@ -10580,10 +10680,20 @@ class ActiveClient extends Disposable { return structuredClone(servers); } + /** An equivalent relocation keeps the session on its applied roots, which plugin cache eviction can delete. */ + private async _hasEvictedRelocatedRoot(applied: readonly ICopilotPluginInfo[], current: readonly ICopilotPluginInfo[]): Promise { + const relocated = applied.flatMap((plugin, index) => { + const root = plugin.pluginDir ?? plugin.resourceDir; + return root && !isEqual(root, current[index]?.pluginDir ?? current[index]?.resourceDir) ? [root] : []; + }); + const exists = await Promise.all(relocated.map(root => this._fileService.exists(root))); + return exists.includes(false); + } + /** Reports the first changed structural contribution without including its contents. */ async getRestartReason(snap: IActiveClientSnapshot, chatKey?: string, current?: IActiveClientSnapshot, anchor?: CustomizationAnchor): Promise { current ??= await this.snapshot(chatKey, anchor); - if (!parsedPluginsEqual(snap.plugins, current.plugins)) { + if (!parsedPluginsEqual(snap.plugins, current.plugins) || await this._hasEvictedRelocatedRoot(snap.plugins, current.plugins)) { return 'clientPluginsChanged'; } if (!equals(snap.mcpServers, current.mcpServers)) { diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 084d80e52f6bdf..63b550bbf43e35 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -50,7 +50,8 @@ import { readCopilotShellAttachment, readCopilotShellId, toCopilotBackgroundShel import { SAVE_SANDBOX_PATH, type ISandboxPathGrant } from '../../common/sandboxPathGrant.js'; import { getCopilotBrowserSandboxNetworkRestrictions } from './copilotSandboxPolicy.js'; import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyAnswer, AgentHostAutoReplyEnabledConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, AgentHostMcpConnectorsEnabledConfigKey, AgentHostMcpSamplingAllowedServersConfigKey, AgentHostMcpToolRoutingEnabledConfigKey, platformRootSchema, platformSessionSchema } from '../../common/agentHostSchema.js'; -import { reconcileCopilotConnectors } from './copilotConnectors.js'; +import { reconcileCopilotConnectors, type ICopilotConnectorServer, type ICopilotConnectorServers } from './copilotConnectors.js'; +import { copilotConnectorsScope } from '../../../copilotConnectors/common/copilotConnectorsRequestService.js'; import { createUnknownAgentHostClientTelemetryContext, type IAgentHostClientTelemetryContext, type IAgentProviderSendStageRecorder } from '../../common/agentHostTelemetry.js'; import { measureAgentProviderOperation } from '../../common/agentHostProviderTiming.js'; import { AgentSession, type IAgentChatOwnerRef, COPILOT_CLI_AGENT_PROVIDER_ID, AgentSignal, AgentWorkingDirectoryChangedError, AuthenticateParams, IMcpNotification, subagentChatTitle, type AgentCanvasExtensionSource, type AgentSubagentTaskModelSource, type AgentTurnProviderCallState, type IAgentCanvasCloseRequest, type IAgentCanvasInfo, type IAgentCanvasOpenRequest, type IAgentPendingMessageSender, type IAgentPermissionResponseContext, type IAgentTelemetryContext, type IAgentToolPendingConfirmationSignal, type IAgentTurnDiagnosticSnapshot, type IAgentTurnTokenUsage } from '../../common/agent.js'; @@ -84,6 +85,7 @@ import { allowCopilotSdkExecution, restoreDeferredCopilotSdkExecution } from './ import { getCopilotSdkToolResourceUri } from './copilotSdkMeta.js'; import { isAutoModel } from './modelIdentifiers.js'; import { clientToolNamesFromSnapshot, composeByokSessionConfig, findLocalModelDirectFamily, isMcpServerExplicitlyProjected, resolveCopilotSessionManagedSettings, toSessionConfigMcpServers, type CopilotSessionLaunchPlan, type IActiveClientSnapshot, type ICopilotSessionLauncher, type ICopilotSessionRuntime } from './copilotSessionLauncher.js'; +import type { ICopilotProjectExtensionTrustGrant } from './copilotProjectExtensionLaunchPolicy.js'; import { CLIENT_TOOL_SEARCH_REFERENCE_NAME, NON_DEFERRED_CLIENT_TOOL_NAMES, RUNTIME_TOOL_SEARCH_TOOL_NAME, searchToolsWithoutClient } from './toolSearchDeferral.js'; import { ActiveClientToolSet, structuralToolsEqual } from '../activeClientState.js'; import { AgentHostTelemetryReporter, toInitiatorTelemetry, type IAgentHostEventClassification, type IAgentHostEventTelemetry, type ICanvasExtensionsReadyEvent } from '../agentHostTelemetryReporter.js'; @@ -95,7 +97,7 @@ import type { IUnsandboxedCommandConfirmationRequest, ShellManager } from './cop import { NonPtyShellTerminalStreams, type INonPtyShellToolCompletion } from './copilotNonPtyShellTerminals.js'; import { readCopilotSessionEvents, restoreStoredShellOutputsForChat } from './copilotSessionHistory.js'; import { CopilotSessionSandbox } from './copilotSessionSandbox.js'; -import { reportCopilotRuntimeRestart, type CopilotRuntimeMutation } from './copilotRuntimeRestartTelemetry.js'; +import { reportCopilotRuntimeRestart, type CopilotRuntimeMutation, type CopilotRuntimeRestartEvent } from './copilotRuntimeRestartTelemetry.js'; import type { CopilotSandboxCertificate } from './copilotSandboxCertificate.js'; import type { IAgentServerToolDefinition, IAgentServerToolHost } from '../../common/agentServerTools.js'; import { AGENT_MERGE_GITHUB_TOOL_RESTRICTION, getAgentMergeGitHubToolRestriction, isAgentMergeRestrictedMcpServer, isCopilotMcpToolName } from '../shared/agentMergeToolRestrictions.js'; @@ -216,6 +218,12 @@ interface IPendingMcpAuthRequest { readonly resource: ProtectedResourceMetadata; readonly requiredScopes: readonly string[]; readonly toolCalls: IMcpAuthToolCall[]; + /** + * The server is owned by a Copilot Connector. The runtime authenticates it only with the session's GitHub + * credential, so a forwarded MCP token never satisfies it; the request is cancelled after Connector + * authorization rebinds that credential and the Connector is reconciled. + */ + readonly copilotConnector: boolean; completion?: Promise; } @@ -229,6 +237,12 @@ interface IMcpAuthToolCall { readonly parentToolCallId: string | undefined; } +/** A server shown as restarting ahead of a forced Connector reconcile, with the state it replaced. */ +interface IProvisionalMcpServerRestart { + readonly previous: McpServerState; + readonly provisional: McpServerState; +} + interface ILastMcpAuthRequirement { readonly requestId: string; readonly auth: McpAuthRequirement; @@ -1142,6 +1156,10 @@ export class CopilotAgentSession extends Disposable { ]; } + get projectExtensionTrustGrant(): ICopilotProjectExtensionTrustGrant | undefined { + return this._launchPlan.projectExtensionTrustGrant; + } + setExtensionLaunchAdmission(admission: IDisposable): void { this._register(admission); } @@ -1390,6 +1408,11 @@ export class CopilotAgentSession extends Disposable { markConnectorConfigurationChanged(forceConnectorName?: string): void { this._pendingConnectorRefreshes.set(forceConnectorName, ++this._connectorRefreshGeneration); + if (forceConnectorName !== undefined && (this.hasActiveTurn || this.hasActiveSubagents)) { + // Reconciliation waits for the turn, which may itself wait on these requests; release them so the + // runtime retries with the session credential that was rebound before this refresh was requested. + this._cancelStaleCopilotConnectorAuthRequests(this._pendingCopilotConnectorAuthRequestIds(forceConnectorName)); + } } get hasPendingConnectorRefresh(): boolean { @@ -1444,12 +1467,17 @@ export class CopilotAgentSession extends Disposable { if (!canStart()) { return; } + let provisionalRestarts: ReadonlyMap | undefined; + let reconciled = false; try { // Apply customization enablement before connector reconciliation checks for stopped servers. await raceCancellationError(this._reconcileMcpServerEnablement(), token); if (!canReconcile()) { return; } + // Requests raised before a forced reconcile carry the credential the reconcile replaces. + const staleAuthRequestIds = forceConnectorName !== undefined ? this._pendingCopilotConnectorAuthRequestIds(forceConnectorName) : []; + provisionalRestarts = this._markCopilotConnectorServersRestarting(staleAuthRequestIds); const result = await reconcileCopilotConnectors(this._wrapper.session, this._logService, { forceConnectorName, canReconcile, @@ -1460,6 +1488,11 @@ export class CopilotAgentSession extends Disposable { this._trackConnectorReconcile(rpc); }, }); + reconciled = result.reconciled; + if (reconciled) { + // The runtime restarted these servers, so their requests are stale even if this refresh is now cancelled. + this._cancelStaleCopilotConnectorAuthRequests(staleAuthRequestIds); + } if (token.isCancellationRequested || this.isDisposed) { return; } @@ -1468,8 +1501,8 @@ export class CopilotAgentSession extends Disposable { if (token.isCancellationRequested || this.isDisposed) { return; } - if (result.displayNames) { - this._setMcpServerDisplayNames(result.displayNames); + if (result.connectorServers) { + this._setCopilotConnectorServers(result.connectorServers); } await raceCancellationError(this._refreshMcpServersFromRpc(), token); if (token.isCancellationRequested || this.isDisposed) { @@ -1487,6 +1520,10 @@ export class CopilotAgentSession extends Disposable { this._logService.warn(`[Copilot:${this.sessionId}] Failed to refresh Connector MCP state; retaining the request for retry`, error); } return; + } finally { + if (!reconciled && provisionalRestarts) { + this._restoreProvisionalMcpServerRestarts(provisionalRestarts); + } } } if (generation === this._connectorRefreshGeneration) { @@ -1495,6 +1532,87 @@ export class CopilotAgentSession extends Disposable { } } + private _pendingCopilotConnectorAuthRequestIds(connectorName: string): readonly string[] { + const requestIds: string[] = []; + for (const [requestId, pending] of this._pendingMcpAuthRequests.entries()) { + if (this._copilotConnectorServers.get(pending.serverName)?.connectorName === connectorName) { + requestIds.push(requestId); + } + } + return requestIds; + } + + /** + * A forced reconcile restarts these servers; show it rather than the challenge the restart supersedes. Returns + * the provisional states so they can be rolled back if reconciliation does not happen. + */ + private _markCopilotConnectorServersRestarting(requestIds: readonly string[]): ReadonlyMap { + const restarts = new Map(); + const serverNames = new Set(requestIds.map(requestId => this._pendingMcpAuthRequests.getMetadata(requestId)?.serverName)); + for (const serverName of serverNames) { + const previous = serverName !== undefined ? this._mcpCustomizations.stateForServer(serverName) : undefined; + if (serverName === undefined || previous?.kind !== McpServerStatus.AuthRequired) { + continue; + } + this._mcpCustomizations.applyOne({ + name: serverName, + displayName: this._copilotConnectorServers.get(serverName)?.displayName, + state: { kind: McpServerStatus.Starting }, + allowAuthRequiredToStarting: true, + }); + const provisional = this._mcpCustomizations.stateForServer(serverName); + if (provisional && provisional !== previous) { + restarts.set(serverName, { previous, provisional }); + } + } + return restarts; + } + + /** + * Restores the challenge of servers whose forced reconcile never ran. A server whose state changed since, or whose + * request no longer waits for authentication, already has a newer lifecycle and is left alone. + */ + private _restoreProvisionalMcpServerRestarts(restarts: ReadonlyMap): void { + if (this.isDisposed) { + return; + } + for (const [serverName, { previous, provisional }] of restarts) { + if (this._mcpCustomizations.stateForServer(serverName) !== provisional || !this._hasPendingMcpAuthentication(serverName)) { + continue; + } + this._logService.info(`[Copilot:${this.sessionId}] Restoring the authentication challenge of Connector-owned MCP server '${serverName}' because its reconciliation did not run`); + this._mcpCustomizations.applyOne({ + name: serverName, + displayName: this._copilotConnectorServers.get(serverName)?.displayName, + state: previous, + }); + } + } + + /** + * The runtime drops its side of these requests when it restarts the Connector's servers with the session + * credential, so release them; requests raised by the restarted servers are kept. + */ + private _cancelStaleCopilotConnectorAuthRequests(requestIds: readonly string[]): void { + for (const requestId of requestIds) { + const pending = this._pendingMcpAuthRequests.getMetadata(requestId); + if (!pending) { + continue; + } + this._disableMcpTokenCompletion(pending.serverName, requestId); + for (const toolCall of pending.toolCalls) { + this._emitAction({ + type: ActionType.ChatToolCallAuthResolved, + turnId: toolCall.turnId, + toolCallId: toolCall.toolCallId, + }, toolCall.parentToolCallId); + } + if (this._pendingMcpAuthRequests.respond(requestId, { kind: 'cancelled' })) { + this._logService.info(`[Copilot:${this.sessionId}] Cancelled a stale authentication request from Connector-owned MCP server '${pending.serverName}' after Connector reconciliation`); + } + } + } + private _trackConnectorReconcile(rpc: Promise): void { const outstanding = rpc.then(() => undefined, () => undefined).finally(() => { if (this._outstandingConnectorReconcile !== outstanding) { @@ -1656,6 +1774,10 @@ export class CopilotAgentSession extends Disposable { private readonly _mcpServerLifecycleSequencer = new SequencerByKey(); private _runtimeMutation: { cause: CopilotRuntimeMutation; sequence: number } | undefined; private readonly _mcpRestartStates = new Map(); + /** The opening launch, whose MCP lifecycle reaches only its launch-time event callback until the wrapper subscribes. */ + private _launchMcpRestartObserver: object | undefined; + /** Restarts observed before the first wrapper attaches; resumed history, which needs the wrapper, decides their first-turn state. */ + private readonly _unattachedMcpRestarts: CopilotRuntimeRestartEvent[] = []; private readonly _steeringMessagesInFlight = new Set(); /** * Steering messages that have been accepted by the SDK but not yet @@ -1711,7 +1833,8 @@ export class CopilotAgentSession extends Disposable { private readonly _connectorRefreshTimeoutMs: number; private readonly _onConnectorRefreshRetryable: (session: CopilotAgentSession) => void; private _mcpServerControlOperations = 0; - private readonly _mcpServerDisplayNames = new Map(); + private readonly _copilotConnectorServers = new Map(); + private _copilotConnectorAuthorizationRequired = false; /** * Retains challenge metadata and its latest callback id so token delivery can report Starting. * Connected and needs-auth statuses remain the final lifecycle authority. @@ -1728,7 +1851,7 @@ export class CopilotAgentSession extends Disposable { * Configuration knowledge that MCP provenance is derived from. Only inventory and configuration reads update * these; lifecycle events never do, so a status change can never alter where a server appears to come from. */ - private _mcpInventoryProvenance: ReadonlyMap> = new Map(); + private _mcpInventoryProvenance: ReadonlyMap> = new Map(); private _userMcpServerNames: ReadonlySet | undefined; private _userMcpServerNamesRequestVersion = 0; /** `pending-edit-content:` URIs written during permission requests, keyed @@ -2021,6 +2144,7 @@ export class CopilotAgentSession extends Disposable { controllingSetting: (serverName, provenance) => serverName === GITHUB_MCP_SERVER_NAME && provenance.source === 'builtin' && !provenance.sourcePlugin ? AgentHostGitHubMcpServerEnabledSettingId : undefined, + connectorName: serverName => this._copilotConnectorServers.get(serverName)?.connectorName, })); this._register(toDisposable(() => this._cancelAllPendingInteractions())); @@ -3879,6 +4003,8 @@ export class CopilotAgentSession extends Disposable { private async _initializeSdkWrapper(token = CancellationToken.None): Promise { while (true) { this._requiresRestartAfterTelemetryRevocation = false; + this._runtimeMutation = undefined; + this._mcpRestartStates.clear(); const launchCancellation = new CancellationTokenSource(this._lifetimeCts.token); const cancellation = token.onCancellationRequested(() => launchCancellation.cancel()); let wrapper: CopilotSessionWrapper; @@ -3905,8 +4031,6 @@ export class CopilotAgentSession extends Disposable { } this._wrapperDisposables.clear(); this._wrapper = this._wrapperDisposables.add(wrapper); - this._runtimeMutation = undefined; - this._mcpRestartStates.clear(); this._lastAppliedAgent = this._launchPlan.kind === 'create' ? this._launchPlan.resolvedAgentName ?? null : undefined; this._registeredByokConfig = wrapper.launchByokConfig; this._reportLocalModelSelection(this._launchPlan.kind === 'create' ? this._launchPlan.model?.id : this._launchPlan.fallback.model?.id); @@ -3934,6 +4058,10 @@ export class CopilotAgentSession extends Disposable { this._logService.error(error, `[Copilot:${this.sessionId}] Failed to release sampling event interest`); } }); + this._launchMcpRestartObserver = undefined; + for (const event of this._unattachedMcpRestarts.splice(0)) { + void reportCopilotRuntimeRestart(this._telemetryService, event, this.completedTurnHistory); + } this._subscribeToEvents(this._wrapperDisposables); this._subscribeToSdkEvents(this._wrapperDisposables); this._readCommittedAutoTier(wrapper); @@ -4394,6 +4522,8 @@ export class CopilotAgentSession extends Disposable { } private _createRuntimeAdapter(cancellationToken: CancellationToken): ICopilotSessionRuntime { + const launch = {}; + this._launchMcpRestartObserver = launch; return { cancellationToken, setApprovalPolicy: (resolved, bridged) => { @@ -4412,6 +4542,9 @@ export class CopilotAgentSession extends Disposable { } void this._managedSettingsResolved.complete(); } + if (this._launchMcpRestartObserver === launch && event.type === 'session.mcp_server_status_changed') { + this._observeMcpRestart(event.data.serverName, event.data.status); + } this._recordSdkTiming(event); this._onSessionEvent?.(event); } @@ -4440,7 +4573,7 @@ export class CopilotAgentSession extends Disposable { recorder?.markMilestone?.('elicitationRequest'); return measureAgentProviderOperation(recorder, 'elicitationRequest', () => this._handleElicitationRequest(context)); }, { action: 'cancel' } satisfies ElicitationResult, 'elicitation'), - setMcpServerDisplayNames: displayNames => this._setMcpServerDisplayNames(displayNames), + setCopilotConnectorServers: connectorServers => this._setCopilotConnectorServers(connectorServers), handleMcpAuthRequest: this._guarded(request => { const recorder = this._currentTurn.value?.timingRecorder; recorder?.markMilestone?.('mcpAuthRequest'); @@ -4460,24 +4593,44 @@ export class CopilotAgentSession extends Disposable { }; } - private _setMcpServerDisplayNames(displayNames: ReadonlyMap): void { - this._mcpServerDisplayNames.clear(); - for (const [serverName, displayName] of displayNames) { - this._mcpServerDisplayNames.set(serverName, displayName); + private _setCopilotConnectorServers(connectorServers: ICopilotConnectorServers): void { + const previous = [...this._copilotConnectorServers.keys()]; + this._copilotConnectorServers.clear(); + for (const [serverName, server] of connectorServers.servers) { + this._copilotConnectorServers.set(serverName, server); + } + this._copilotConnectorAuthorizationRequired = connectorServers.authorizationRequired; + // Republish servers that stopped being Connector-owned too, so their Connector metadata is removed. + for (const serverName of new Set([...previous, ...connectorServers.servers.keys()])) { const state = this._mcpCustomizations.stateForServer(serverName); if (state) { - this._mcpCustomizations.applyOne({ name: serverName, displayName, state }); + this._mcpCustomizations.applyOne({ name: serverName, displayName: this._copilotConnectorServers.get(serverName)?.displayName, state }); } } } - /** Resolves only matching, currently pending SDK authentication callbacks. */ + /** + * Whether a server is owned by a Copilot Connector, either as reported by Connector reconciliation or, when + * that is unavailable, because its challenge asks for the Connector authorization scope. + */ + private _isCopilotConnectorServer(serverName: string, requiredScopes: readonly string[] = []): boolean { + return this._copilotConnectorServers.has(serverName) || requiredScopes.includes(copilotConnectorsScope); + } + + /** + * Resolves only matching, currently pending SDK authentication callbacks. Connector-owned servers are never + * resolved with a forwarded MCP token: the runtime ignores it and keeps using the session credential. + */ async resolveMcpAuthentication(params: AuthenticateParams): Promise { let resolved = false; for (const [requestId, pending] of this._pendingMcpAuthRequests.entries()) { if (pending.resource.resource !== params.resource || !this._scopesSatisfy(params.scopes, pending.requiredScopes)) { continue; } + if (pending.copilotConnector || this._isCopilotConnectorServer(pending.serverName)) { + this._logService.info(`[Copilot:${this.sessionId}] Ignoring an MCP token for Connector-owned server '${pending.serverName}'; it requires Copilot Connector authorization of the session account`); + continue; + } for (const toolCall of pending.toolCalls) { this._emitAction({ type: ActionType.ChatToolCallAuthResolved, @@ -4560,22 +4713,24 @@ export class CopilotAgentSession extends Disposable { }; this._mcpLifecycleVersion++; const toolCalls = this._activeMcpToolCalls(request.serverName); + const copilotConnector = this._isCopilotConnectorServer(request.serverName, requiredScopes); const pending: IPendingMcpAuthRequest = { serverName: request.serverName, resource, requiredScopes, toolCalls, + copilotConnector, }; const result = this._pendingMcpAuthRequests.register(request.requestId, pending); pending.completion = result; this._lastMcpAuthRequirements.set(request.serverName, { requestId: request.requestId, auth, - acceptsTokenCompletion: true, + acceptsTokenCompletion: !copilotConnector, }); this._mcpCustomizations.applyOne({ name: request.serverName, - displayName: this._mcpServerDisplayNames.get(request.serverName), + displayName: this._copilotConnectorServers.get(request.serverName)?.displayName, state: { kind: McpServerStatus.AuthRequired, ...auth, @@ -4589,7 +4744,9 @@ export class CopilotAgentSession extends Disposable { auth, }, toolCall.parentToolCallId); } - this._logService.info(`[Copilot:${this.sessionId}] MCP server '${request.serverName}' requires authentication for ${resource.resource}`); + this._logService.info(copilotConnector + ? `[Copilot:${this.sessionId}] Connector-owned MCP server '${request.serverName}' requires Copilot Connector authorization of the session account (${request.reason})` + : `[Copilot:${this.sessionId}] MCP server '${request.serverName}' requires authentication for ${resource.resource}`); try { return await result; } catch (error) { @@ -6308,13 +6465,18 @@ export class CopilotAgentSession extends Disposable { const restarted = status === 'pending' && (previous?.stopped || previous?.connected); if (restarted) { const turn = this._currentTurn.value; - void reportCopilotRuntimeRestart(this._telemetryService, { + const event: CopilotRuntimeRestartEvent = { ...createCopilotFailureCorrelation(this.resourceUri, this._chatChannelUri, turn?.id, this.sessionId, turn?.clientContext), kind: 'mcpServer', cause: this._runtimeMutation && this._runtimeMutation.sequence > previous.sequence ? this._runtimeMutation.cause : 'runtime', firstTurn: !this.hasCompletedTurn, phase: !turn ? 'idle' : turn.activeSdkTurnId ? 'inTurn' : 'turnPrepare', - }, this.completedTurnHistory); + }; + if (this._wrapper) { + void reportCopilotRuntimeRestart(this._telemetryService, event, this.completedTurnHistory); + } else { + this._unattachedMcpRestarts.push(event); + } } if (running) { this._mcpRestartStates.set(serverName, { sequence: previous?.running && !restarted ? previous.sequence : sequence, running: true, connected: status === 'connected', stopped: false }); @@ -6830,7 +6992,13 @@ export class CopilotAgentSession extends Disposable { const isNewFile = edits?.items.some(edit => !edit.before && !!edit.after); const trackedToolCall = this._activeToolCalls.get(toolCallId); - const { confirmationTitle, invocationMessage, toolInput, permissionKind, permissionPath, shellCommand } = getPermissionDisplay(request, this._workingDirectory, isNewFile, this._appliedAdditionalDirectories, trackedToolCall?.parameters); + const permissionDisplay = getPermissionDisplay(request, this._workingDirectory, isNewFile, this._appliedAdditionalDirectories, trackedToolCall?.parameters); + // A server tool's confirmation as its group builds it with the host's state (e.g. linking only known sessions). + const serverConfirmation = serverToolName && request.kind === 'custom-tool' ? serverToolHost?.getConfirmationDisplay(serverToolName, request.args) : undefined; + const { permissionKind, permissionPath, shellCommand } = permissionDisplay; + const confirmationTitle = serverConfirmation?.confirmationTitle ?? permissionDisplay.confirmationTitle; + const invocationMessage = serverConfirmation?.confirmationMessage ?? permissionDisplay.invocationMessage; + const toolInput = serverConfirmation?.hideConfirmationInput ? undefined : permissionDisplay.toolInput; // Fire a pending_confirmation signal to transition the tool to PendingConfirmation const toolName = request.kind === 'mcp' || request.kind === 'custom-tool' || request.kind === 'hook' @@ -9728,7 +9896,7 @@ export class CopilotAgentSession extends Disposable { this._lastMcpAuthRequirements.delete(serverName); } } - this._mcpInventoryProvenance = new Map(servers.map(server => [server.name, { source: server.source, sourcePlugin: server.sourcePlugin }])); + this._mcpInventoryProvenance = new Map(servers.map(server => [server.name, { source: server.source, sourcePlugin: server.sourcePlugin, url: server.url }])); const sdkServers = servers.map(server => this._toSdkMcpServer(server)); this._mcpCustomizations.applyAll(sdkServers); for (const server of servers) { @@ -9877,7 +10045,7 @@ export class CopilotAgentSession extends Disposable { const hasPendingAuthentication = this._hasPendingMcpAuthentication(server.name); return { name: server.name, - displayName: this._mcpServerDisplayNames.get(server.name), + displayName: this._copilotConnectorServers.get(server.name)?.displayName, state: this._translateSdkMcpStatus(server.name, server.status, server.error, hasPendingAuthentication), ...((server.status === 'pending' && !hasPendingAuthentication) || server.status === 'connected' ? { allowAuthRequiredToStarting: true } : {}), enabled: getEffectiveMcpServerEnablement(server.enabled, server.enabled === undefined ? server.status : server.live?.status), @@ -9933,7 +10101,7 @@ export class CopilotAgentSession extends Disposable { if (hasPendingAuthentication && previous?.kind === McpServerStatus.AuthRequired) { return previous; } - const auth = this._lastMcpAuthRequirements.get(name)?.auth; + const auth = this._lastMcpAuthRequirements.get(name)?.auth ?? this._copilotConnectorAuthRequirement(name); return auth ? { kind: McpServerStatus.AuthRequired, ...auth } : { kind: McpServerStatus.Starting }; } case 'disabled': @@ -9945,6 +10113,26 @@ export class CopilotAgentSession extends Disposable { } } + /** + * A runtime that classifies a Connector scope failure reports it on the Connector status instead of asking for + * an MCP token, so describe the requirement for a Connector-owned server that needs authentication. + */ + private _copilotConnectorAuthRequirement(serverName: string): McpAuthRequirement | undefined { + const connector = this._copilotConnectorServers.get(serverName); + if (!connector || !this._copilotConnectorAuthorizationRequired) { + return undefined; + } + return { + reason: McpAuthRequiredReason.InsufficientScope, + resource: { + resource: this._mcpInventoryProvenance.get(serverName)?.url ?? serverName, + resource_name: connector.displayName, + scopes_supported: [copilotConnectorsScope], + }, + requiredScopes: [copilotConnectorsScope], + }; + } + /** * Translates the SDK's three-mode space (`interactive` / `plan` / * `autopilot`) to AHP's `mode` axis of this chat: diff --git a/src/vs/platform/agentHost/node/copilot/copilotAttachmentUtils.ts b/src/vs/platform/agentHost/node/copilot/copilotAttachmentUtils.ts index 3fcc478adc0f90..d89708d17524c9 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAttachmentUtils.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAttachmentUtils.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { decodeBase64 } from '../../../../base/common/buffer.js'; import type { SimpleMessageAttachment } from '../../common/state/protocol/state.js'; const attachmentDisplayKindParameter = 'x-vscode-display-kind='; @@ -30,3 +31,102 @@ export function readSimpleAttachmentDisplayKindFromMimeType(mimeType: string): s return undefined; } } + +/** + * Decodes a `blob` attachment's `data` field for a text/simple attachment. + * Handles standard/URL-safe base64, line-wrapped base64, `data:` URLs, and + * legacy/external sessions that persisted raw unencoded text in `data`. + * + * @param knownEncoded The producer is known to base64-encode `data` (e.g. a + * VS Code-specific mime type), so it is decoded whenever possible instead of + * being checked against the raw-text heuristic. + */ +export function decodeTextBlobAttachmentData(data: string, knownEncoded: boolean): string { + if (!data) { + return ''; + } + const dataUrlMatch = /^data:[^,]*?(?;base64)?,(?[\s\S]*)$/i.exec(data); + if (dataUrlMatch?.groups) { + const { base64, payload } = dataUrlMatch.groups; + if (base64) { + return decodeBase64Text(payload.replace(/[\t\n\v\f\r ]+/g, '')) ?? payload; + } + try { + return decodeURIComponent(payload); + } catch { + return payload; + } + } + const unwrapped = data.replace(/[\r\n]+/g, ''); + return (knownEncoded ? decodeBase64Text(unwrapped) : tryDecodeBase64Text(unwrapped)) ?? data; +} + +const base64TextPattern = /^(?[A-Za-z0-9+/_-]*)(?={0,2})$/; +const base64Alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +/** + * Whether `body` and `padding` are what an encoder would produce: the body is + * non-empty with a possible length, the unused low bits of its last character + * are zero, and any padding completes the final 4-character group. + */ +function isCanonicalBase64(body: string, padding: string): boolean { + const remainder = body.length % 4; + if (body.length === 0 || remainder === 1 || (padding.length > 0 && (remainder + padding.length) !== 4)) { + return false; + } + if (remainder === 0) { + return true; + } + const last = body[body.length - 1]; + const value = base64Alphabet.indexOf(last === '-' ? '+' : last === '_' ? '/' : last); + return (value & (remainder === 2 ? 0b1111 : 0b11)) === 0; +} + +/** + * Decodes base64 that is known to be encoded text, replacing invalid UTF-8 + * sequences. Returns `undefined` only when `payload` is not base64 at all. + */ +function decodeBase64Text(payload: string): string | undefined { + try { + return new TextDecoder('utf-8', { ignoreBOM: true }).decode(decodeBase64(payload).buffer); + } catch { + return undefined; + } +} + +/** + * Decodes `payload` only when it is canonical base64 of valid UTF-8, which + * output of VS Code's own encoder always is. Returns `undefined` otherwise so + * callers can treat it as raw unencoded text (e.g. `README` or `a=b`) instead + * of producing garbage. + */ +function tryDecodeBase64Text(payload: string): string | undefined { + const groups = base64TextPattern.exec(payload)?.groups; + if (!groups || !isCanonicalBase64(groups.body, groups.padding)) { + return undefined; + } + try { + return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(decodeBase64(groups.body).buffer); + } catch { + return undefined; + } +} + +/** + * Normalizes a `blob` attachment's `data` field for binary/embedded resources + * by stripping a leading `data:...;base64,` prefix and the line breaks of + * line-wrapped base64. Line breaks are only removed when the result is + * canonical base64; other payloads, including raw text with spaces such as + * `not base64`, are kept as-is so consumers fail to decode them and fall back + * instead of producing garbage bytes. + */ +export function normalizeBase64AttachmentData(data: string): string { + if (!data) { + return ''; + } + const dataUrlMatch = /^data:[^,]*;base64,(?[\s\S]*)$/i.exec(data); + const raw = dataUrlMatch?.groups?.payload ?? data; + const unwrapped = raw.replace(/[\r\n]+/g, ''); + const groups = base64TextPattern.exec(unwrapped)?.groups; + return groups && isCanonicalBase64(groups.body, groups.padding) ? unwrapped : raw; +} diff --git a/src/vs/platform/agentHost/node/copilot/copilotConnectors.ts b/src/vs/platform/agentHost/node/copilot/copilotConnectors.ts index 0cbe85ef454669..2ecb43e8728f25 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotConnectors.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotConnectors.ts @@ -9,6 +9,22 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { getErrorMessage, isCancellationError } from '../../../../base/common/errors.js'; import { ILogService } from '../../../log/common/log.js'; +/** One Connector-owned runtime MCP server. */ +export interface ICopilotConnectorServer { + /** Canonical Connector name, used for targeted reconciliation. */ + readonly connectorName: string; + /** Catalog display name, falling back to {@link connectorName}. */ + readonly displayName: string; +} + +/** The Connector-owned runtime servers of one SDK session and whether the account must authorize Connectors. */ +export interface ICopilotConnectorServers { + /** Keyed by runtime server ID, which is also the MCP server name. */ + readonly servers: ReadonlyMap; + /** The runtime reports that the session account lacks the Connector authorization scope. */ + readonly authorizationRequired: boolean; +} + /** Uses this SDK session's account handle; a failed attempt retains current state for publication and retry. */ export async function reconcileCopilotConnectors( session: CopilotSession, @@ -20,7 +36,7 @@ export async function reconcileCopilotConnectors( readonly token?: CancellationToken; readonly onReconcileRequested?: (rpc: Promise) => void; } = {}, -): Promise<{ readonly reconciled: boolean; readonly displayNames?: ReadonlyMap }> { +): Promise<{ readonly reconciled: boolean; readonly connectorServers?: ICopilotConnectorServers }> { const { forceConnectorName, canReconcile = () => true, token = CancellationToken.None } = options; let status: ConnectorStatus; let reconciled = false; @@ -59,7 +75,7 @@ export async function reconcileCopilotConnectors( && (server.status === 'stopped' || server.status === 'not_configured' || server.status === 'disabled') )) { logService.info(`[Copilot:${session.sessionId}] Deferring Connector MCP reconciliation to preserve stopped or disabled servers`); - return { reconciled, displayNames: connectorDisplayNames(current) }; + return { reconciled, connectorServers: copilotConnectorServers(current) }; } } const reconcile = session.rpc.connectors.reconcile({ @@ -91,11 +107,17 @@ export async function reconcileCopilotConnectors( } return { reconciled, - displayNames: connectorDisplayNames(status), + connectorServers: copilotConnectorServers(status), }; } -function connectorDisplayNames(status: ConnectorStatus): ReadonlyMap { +function copilotConnectorServers(status: ConnectorStatus): ICopilotConnectorServers { const names = new Map(status.catalog?.connectors.map(connector => [connector.name, connector.displayName.trim() || connector.name])); - return new Map(status.runtimeServers.map(server => [server.runtimeServerId, names.get(server.connectorName) ?? server.connectorName])); + return { + servers: new Map(status.runtimeServers.map(server => [server.runtimeServerId, { + connectorName: server.connectorName, + displayName: names.get(server.connectorName) ?? server.connectorName, + }])), + authorizationRequired: status.authorizationRequirement !== undefined, + }; } diff --git a/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts b/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts index be16a02185bf29..ac8e0a948b4b79 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts @@ -10,11 +10,11 @@ import { dirname } from '../../../../base/common/path.js'; import { OperatingSystem, OS } from '../../../../base/common/platform.js'; import { escapeRegExpCharacters } from '../../../../base/common/strings.js'; import { URI } from '../../../../base/common/uri.js'; -import { parseFrontMatter } from '../../../../base/common/yaml.js'; +import { parseFrontMatter, type MarkdownNode, type YamlNode } from '../../../../base/common/yaml.js'; import { IFileService } from '../../../files/common/files.js'; import { toCopilotMcpServerConfiguration } from '../../../mcp/common/mcpCopilotConfiguration.js'; import { McpServerType, type IMcpServerConfiguration } from '../../../mcp/common/mcpPlatformTypes.js'; -import type { IMcpServerDefinition, INamedPluginResource, IParsedHookCommand, IParsedHookGroup, IParsedPlugin } from '../../../agentPlugins/common/pluginParsers.js'; +import { resolveAgentDisableModelInvocation, type IMcpServerDefinition, type INamedPluginResource, type IParsedHookCommand, type IParsedHookGroup, type IParsedPlugin } from '../../../agentPlugins/common/pluginParsers.js'; import type { ChildCustomization } from '../../common/state/protocol/state.js'; import { CLIENT_TOOL_SEARCH_REFERENCE_NAME, RUNTIME_TOOL_SEARCH_TOOL_NAME } from '../../common/toolSearchConstants.js'; import { resolveMcpServerWorkingDirectory } from '../shared/mcpServerWorkingDirectory.js'; @@ -97,10 +97,73 @@ function toSdkMcpServer(_name: string, config: IMcpServerConfiguration, defaultC const customAgentReasoningEfforts = ['low', 'medium', 'high', 'xhigh', 'max'] as const satisfies readonly NonNullable[]; type CustomAgentReasoningEffort = (typeof customAgentReasoningEfforts)[number]; +/** Runtime custom-agent fields not yet declared by the SDK. */ +interface ICopilotCustomAgentConfig extends CustomAgentConfig { + readonly disableModelInvocation?: true; +} + function isCustomAgentReasoningEffort(value: string | undefined): value is CustomAgentReasoningEffort { return customAgentReasoningEfforts.some(reasoningEffort => reasoningEffort === value); } +function yamlNodeToValue(node: YamlNode): unknown { + if (node.type === 'scalar') { + if (node.format !== 'none') { + return node.value; + } + if (node.value === 'true') { + return true; + } + if (node.value === 'false') { + return false; + } + if (node.value === 'null' || node.value === '~') { + return null; + } + if (/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(node.value)) { + return Number(node.value); + } + return node.value; + } + if (node.type === 'sequence') { + return node.items.map(yamlNodeToValue); + } + return Object.fromEntries(node.properties.map(property => [property.key.value, yamlNodeToValue(property.value)])); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function getCustomAgentHooks(md: MarkdownNode): CustomAgentConfig['hooks'] { + if (md.header?.type !== 'map') { + return undefined; + } + const hooksNode = md.header.properties.find(property => property.key.value === 'hooks')?.value; + if (!hooksNode || hooksNode.type !== 'map') { + return undefined; + } + + const hooks: [string, Record[]][] = []; + for (const property of hooksNode.properties) { + if (property.value.type !== 'sequence') { + continue; + } + const commands: Record[] = []; + for (const item of property.value.items) { + const command = yamlNodeToValue(item); + if (!isRecord(command)) { + continue; + } + commands.push(command); + } + if (commands.length > 0) { + hooks.push([property.key.value, commands]); + } + } + return hooks.length > 0 ? Object.fromEntries(hooks) : undefined; +} + /** * Converts parsed plugin agents into the SDK's `customAgents` config. * @@ -113,8 +176,8 @@ function isCustomAgentReasoningEffort(value: string | undefined): value is Custo * - `prompt` is the markdown body that follows the frontmatter (or the * full file content when there is no frontmatter). */ -export async function toSdkCustomAgents(agents: readonly INamedPluginResource[], fileService: IFileService): Promise { - const configs: CustomAgentConfig[] = []; +export async function toSdkCustomAgents(agents: readonly INamedPluginResource[], fileService: IFileService): Promise { + const configs: ICopilotCustomAgentConfig[] = []; for (const agent of agents) { try { const content = await fileService.readFile(agent.uri); @@ -134,9 +197,10 @@ export async function toSdkCustomAgents(agents: readonly INamedPluginResource[], const description = md.getStringValue('description'); const tools = md.getStringArrayValue('tools'); const skills = md.getStringArrayValue('skills'); + const hooks = getCustomAgentHooks(md); const reasoningEffort = md.getStringValue('reasoning-effort'); let infer = md.getBooleanValue('infer'); - const disableModelInvocation = md.getBooleanValue('disable-model-invocation'); + const disableModelInvocation = resolveAgentDisableModelInvocation(infer, md.getBooleanValue('disable-model-invocation')); if (infer === undefined && disableModelInvocation === true) { infer = false; } @@ -153,7 +217,9 @@ export async function toSdkCustomAgents(agents: readonly INamedPluginResource[], ...(isCustomAgentReasoningEffort(reasoningEffort) ? { reasoningEffort } : {}), tools: tools && tools.length > 0 ? tools : null, ...(skills !== undefined ? { skills } : {}), + ...(hooks ? { hooks } : {}), ...(infer !== undefined ? { infer } : {}), + ...(disableModelInvocation ? { disableModelInvocation: true } : {}), prompt, }); } @@ -180,7 +246,7 @@ export interface IPluginAgentsForSdk { export async function toSdkSessionCustomAgents( plugins: readonly IPluginAgentsForSdk[], fileService: IFileService, -): Promise { +): Promise { return toSdkCustomAgents(plugins.flatMap(plugin => plugin.agents), fileService); } @@ -467,17 +533,16 @@ export function toSdkHooks( return hooks; } +type ComparablePlugin = IParsedPlugin & { readonly pluginDir?: URI; readonly resourceDir?: URI; readonly nonce?: string }; + /** - * Compares SDK configuration relative to each plugin's own materialized root, preserving plugin order. - * Client nonces retain resource-body revisions that are not represented in parsed metadata. + * Compares SDK configuration, preserving plugin order. A plugin with a content nonce compares relative to its own + * materialized root, since the nonce covers resource bodies that parsed metadata does not; others compare by absolute path. */ -export function parsedPluginsEqual( - a: readonly (IParsedPlugin & { readonly pluginDir?: URI; readonly resourceDir?: URI; readonly nonce?: string })[], - b: readonly (IParsedPlugin & { readonly pluginDir?: URI; readonly resourceDir?: URI; readonly nonce?: string })[], -): boolean { +export function parsedPluginsEqual(a: readonly ComparablePlugin[], b: readonly ComparablePlugin[]): boolean { const serialize = (plugins: typeof a) => { return JSON.stringify(plugins.map(p => { - const root = p.pluginDir ?? p.resourceDir; + const root = p.nonce ? p.pluginDir ?? p.resourceDir : undefined; const roots = root ? [...new Set([root.toString(), ...(root.scheme === Schemas.file ? [root.fsPath] : [])])] : []; const rootPattern = roots.length ? new RegExp(`(?:])`, 'g') @@ -509,3 +574,16 @@ export function parsedPluginsEqual( }; return serialize(a) === serialize(b); } + +/** Resolves a published agent URI to the agent name a session applied before an equivalent relocation of its plugin. */ +export function findRelocatedAgentName(applied: readonly ComparablePlugin[], published: readonly ComparablePlugin[], agentUri: string): string | undefined { + for (const plugin of published) { + const index = plugin.agents.findIndex(agent => agent.uri.toString() === agentUri); + // Applied and published sets can come from different chat anchors, so their plugin positions can differ. + const equivalent = index >= 0 ? applied.find(candidate => parsedPluginsEqual([candidate], [plugin])) : undefined; + if (equivalent) { + return equivalent.agents[index]?.name; + } + } + return undefined; +} diff --git a/src/vs/platform/agentHost/node/copilot/copilotProjectExtensionLaunchPolicy.ts b/src/vs/platform/agentHost/node/copilot/copilotProjectExtensionLaunchPolicy.ts new file mode 100644 index 00000000000000..827009fc782d9b --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/copilotProjectExtensionLaunchPolicy.ts @@ -0,0 +1,251 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as fs from 'fs/promises'; +import { structuralEquals } from '../../../../base/common/equals.js'; +import { getErrorMessage } from '../../../../base/common/errors.js'; +import { IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { ResourceMap } from '../../../../base/common/map.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { isAbsolute } from '../../../../base/common/path.js'; +import { basename, extUriBiasedIgnorePathCase, isEqual, isEqualOrParent } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ILogService } from '../../../log/common/log.js'; +import { AgentHostWorkspaceTrustConfigKey, areAllAgentHostWorkspaceResourcesTrusted, platformRootSchema } from '../../common/agentHostSchema.js'; +import { getRepositoryRootFromWorktree, isWorktreeUnderRepository } from '../../common/worktreePaths.js'; +import { IAgentConfigurationService } from '../agentConfigurationService.js'; + +export interface ICopilotProjectExtensionTrustGrant { + readonly worktreeDirectory: URI; + readonly repositoryRoot: URI; +} + +export type CopilotProjectExtensionTrustProvenance = + | { readonly kind: 'knownRepository'; readonly repositoryRoot: URI } + | { readonly kind: 'hostCreatedWorktree'; readonly repositoryRoot?: URI }; + +type CopilotExtensionSource = 'project' | 'user' | 'plugin' | 'session'; + +export function serializeCopilotProjectExtensionTrustGrant(grant: ICopilotProjectExtensionTrustGrant | undefined): string { + return grant ? JSON.stringify({ + worktreeDirectory: grant.worktreeDirectory.toString(), + repositoryRoot: grant.repositoryRoot.toString(), + }) : ''; +} + +export function parseCopilotProjectExtensionTrustGrant(value: string | undefined): ICopilotProjectExtensionTrustGrant | undefined { + if (!value) { + return undefined; + } + try { + const parsed = JSON.parse(value) as { worktreeDirectory?: unknown; repositoryRoot?: unknown }; + return typeof parsed.worktreeDirectory === 'string' && typeof parsed.repositoryRoot === 'string' + ? { worktreeDirectory: URI.parse(parsed.worktreeDirectory), repositoryRoot: URI.parse(parsed.repositoryRoot) } + : undefined; + } catch { + return undefined; + } +} + +export class CopilotProjectExtensionLaunchPolicy { + private readonly _admittedRoots = new ResourceMap(); + private readonly _inheritedTrustByWorktree = new ResourceMap>(); + + constructor( + private readonly _configurationService: IAgentConfigurationService, + private readonly _logService: ILogService, + private readonly _realpath: (path: string) => Promise = fs.realpath, + ) { } + + async acquireAdmission(directories: readonly URI[], trustGrant: ICopilotProjectExtensionTrustGrant | undefined): Promise { + const roots: URI[] = []; + for (const directory of directories) { + const canonical = await this._canonicalFileUri(directory, `extension launch root '${directory.fsPath}'`); + if (canonical) { + roots.push(canonical); + this._admittedRoots.set(canonical, (this._admittedRoots.get(canonical) ?? 0) + 1); + } + } + + let acquiredGrant: ICopilotProjectExtensionTrustGrant | undefined; + if (trustGrant) { + const worktreeDirectory = await this._canonicalFileUri(trustGrant.worktreeDirectory, 'inherited extension trust worktree'); + const repositoryRoot = await this._canonicalFileUri(trustGrant.repositoryRoot, 'inherited extension trust repository'); + if (worktreeDirectory && repositoryRoot + && isEqual(worktreeDirectory, trustGrant.worktreeDirectory) + && isEqual(repositoryRoot, trustGrant.repositoryRoot) + && roots.some(root => isEqual(root, worktreeDirectory)) + && isWorktreeUnderRepository(worktreeDirectory, repositoryRoot)) { + acquiredGrant = { worktreeDirectory, repositoryRoot }; + const repositories = this._inheritedTrustByWorktree.get(worktreeDirectory) ?? new ResourceMap(); + repositories.set(repositoryRoot, (repositories.get(repositoryRoot) ?? 0) + 1); + this._inheritedTrustByWorktree.set(worktreeDirectory, repositories); + } + } + + return toDisposable(() => { + for (const root of roots) { + this._releaseReference(this._admittedRoots, root); + } + if (acquiredGrant) { + const repositories = this._inheritedTrustByWorktree.get(acquiredGrant.worktreeDirectory); + if (repositories) { + this._releaseReference(repositories, acquiredGrant.repositoryRoot); + if (repositories.size === 0) { + this._inheritedTrustByWorktree.delete(acquiredGrant.worktreeDirectory); + } + } + } + }); + } + + async resolveLaunch(modulePath: string, source: CopilotExtensionSource, extensionBootstrapPath: string) { + if (!isAbsolute(modulePath) || basename(URI.file(modulePath)) !== 'extension.mjs' || source === 'session') { + return {}; + } + + let launchModulePath = modulePath; + if (source === 'project') { + const canonicalModule = await this._canonicalFileUri(URI.file(modulePath)); + if (!canonicalModule || basename(canonicalModule) !== 'extension.mjs' || !this._isAdmitted(canonicalModule)) { + return {}; + } + const trusted = await this._isLaunchTrusted(canonicalModule); + // Trust resolution awaits filesystem/configuration state, so admission must still be live afterward. + if (!trusted || !this._isAdmitted(canonicalModule)) { + return {}; + } + launchModulePath = canonicalModule.fsPath; + } + + return { + launch: { + executable: process.execPath, + args: [extensionBootstrapPath], + env: { + EXTENSION_PATH: launchModulePath, + ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: '1' } : {}), + }, + }, + }; + } + + async resolveTrustGrant(workingDirectory: URI | undefined, provenance: CopilotProjectExtensionTrustProvenance): Promise { + if (!workingDirectory) { + return undefined; + } + const worktreeDirectory = await this._canonicalFileUri(workingDirectory); + if (!worktreeDirectory) { + return undefined; + } + + let repositoryRoot: URI | undefined; + const repository = provenance.repositoryRoot; + if (repository) { + const candidate = await this._canonicalFileUri(repository); + if (candidate && isWorktreeUnderRepository(worktreeDirectory, candidate)) { + repositoryRoot = candidate; + } + } + if (!repositoryRoot && provenance.kind === 'hostCreatedWorktree') { + const derived = getRepositoryRootFromWorktree(worktreeDirectory); + repositoryRoot = derived ? await this._canonicalFileUri(derived) : undefined; + } + if (!repositoryRoot || !isWorktreeUnderRepository(worktreeDirectory, repositoryRoot)) { + return undefined; + } + return await this._isConfiguredFileTrusted(repositoryRoot) + ? { worktreeDirectory, repositoryRoot } + : undefined; + } + + async resolvePersistedTrustGrant(workingDirectory: URI, persisted: ICopilotProjectExtensionTrustGrant | undefined): Promise { + if (!persisted) { + return undefined; + } + const resolved = await this.resolveTrustGrant(workingDirectory, { + kind: 'knownRepository', + repositoryRoot: persisted.repositoryRoot, + }); + return resolved && isEqual(resolved.worktreeDirectory, persisted.worktreeDirectory) ? resolved : undefined; + } + + private async _isLaunchTrusted(resource: URI): Promise { + if (await this._isConfiguredFileTrusted(resource)) { + return true; + } + for (const [worktreeDirectory, repositories] of this._inheritedTrustByWorktree) { + if (!isEqualOrParent(resource, worktreeDirectory)) { + continue; + } + for (const repositoryRoot of repositories.keys()) { + if (await this._isConfiguredFileTrusted(repositoryRoot) && repositories.has(repositoryRoot)) { + return true; + } + } + } + return false; + } + + private async _isConfiguredFileTrusted(resource: URI): Promise { + const trust = this._configurationService.getRootValue(platformRootSchema, AgentHostWorkspaceTrustConfigKey); + if (!trust) { + return false; + } + if (areAllAgentHostWorkspaceResourcesTrusted(trust)) { + return true; + } + const trustedRoots = await Promise.all(trust.trustedUris.map(async uri => { + try { + return await this._canonicalFileUri(URI.parse(uri)); + } catch { + return undefined; + } + })); + const currentTrust = this._configurationService.getRootValue(platformRootSchema, AgentHostWorkspaceTrustConfigKey); + if (!currentTrust) { + return false; + } + if (areAllAgentHostWorkspaceResourcesTrusted(currentTrust)) { + return true; + } + if (!structuralEquals(trust, currentTrust)) { + return false; + } + return trustedRoots.some(root => root && extUriBiasedIgnorePathCase.isEqualOrParent(resource, root)); + } + + private _isAdmitted(resource: URI): boolean { + for (const root of this._admittedRoots.keys()) { + if (isEqualOrParent(resource, root)) { + return true; + } + } + return false; + } + + private async _canonicalFileUri(resource: URI, label?: string): Promise { + if (resource.scheme !== Schemas.file) { + return undefined; + } + try { + return URI.file(await this._realpath(resource.fsPath)); + } catch (error) { + if (label) { + this._logService.warn(`[Copilot] Failed to canonicalize ${label}: ${getErrorMessage(error)}`); + } + return undefined; + } + } + + private _releaseReference(references: ResourceMap, resource: URI): void { + const count = references.get(resource); + if (count === undefined || count <= 1) { + references.delete(resource); + } else { + references.set(resource, count - 1); + } + } +} diff --git a/src/vs/platform/agentHost/node/copilot/copilotRuntimeRestartTelemetry.ts b/src/vs/platform/agentHost/node/copilot/copilotRuntimeRestartTelemetry.ts index eb140eb9a5c743..fac53e7bd5a5ac 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotRuntimeRestartTelemetry.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotRuntimeRestartTelemetry.ts @@ -10,7 +10,7 @@ export type CopilotSessionRefreshReason = 'workingDirectoryChanged' | 'primaryDi export type CopilotRuntimeMutation = 'sandboxUpdate' | 'mcpEnable' | 'mcpDisable' | 'agentSelect' | 'userRequested' | 'extensionsReload' | 'customizationsChanged'; type CopilotRuntimeRestartPhase = 'turnPrepare' | 'inTurn' | 'idle'; -type CopilotRuntimeRestartEvent = IAgentHostEventTelemetry & { +export type CopilotRuntimeRestartEvent = IAgentHostEventTelemetry & { kind: 'sdkSession' | 'mcpServer'; cause: CopilotSessionRefreshReason | CopilotRuntimeMutation | 'runtime'; firstTurn: boolean; diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index af5a93a261a10b..dec41455faba5a 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -26,6 +26,7 @@ import { getCopilotApprovalPolicy } from './copilotApprovalPolicy.js'; import { AGENT_HOST_COPILOT_CLIENT_NAME, type CopilotManagedSettingsResolver, resolveCopilotManagedSettings } from './copilotManagedSettings.js'; import { omitCopilotManagedModelDefaults, type CopilotManagedModelPolicy } from './copilotManagedModelConfiguration.js'; import { CopilotCliConfigKey, CopilotCliVSCodeAssignmentContextKey, copilotCliConfigSchema, normalizeModelFamilyAlias, normalizeToolSearchDeferThreshold, resolveModelCapabilityOverrideField } from '../../common/copilotCliConfig.js'; +import type { ICopilotProjectExtensionTrustGrant } from './copilotProjectExtensionLaunchPolicy.js'; import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; import { reasoningEffortLevels, type ReasoningEffortLevel } from '../../common/reasoningEffort.js'; import { projectCopilotSandboxPolicy } from './copilotSandboxPolicy.js'; @@ -42,7 +43,7 @@ import { IByokLmBridgeRegistry } from '../byokLmBridgeRegistry.js'; import { IByokLmProxyService, type IByokLmProxyHandle } from './byokLmProxyService.js'; import type { ICopilotMcpServerInfo, ICopilotPluginInfo } from './copilotAgent.js'; import { CopilotGitHubSessionCredentials } from './copilotGitHubCredentials.js'; -import { reconcileCopilotConnectors } from './copilotConnectors.js'; +import { reconcileCopilotConnectors, type ICopilotConnectorServers } from './copilotConnectors.js'; import { toSdkClientToolName, toSdkHooks, toSdkInstructionDirectories, toSdkMcpServers, toSdkMcpServersFromConfigMap, toSdkSessionCustomAgents, toSdkSkillDirectories } from './copilotPluginConverters.js'; import { buildCopilotBuiltinAgents, COPILOT_BUILTIN_AGENT_NAMES } from './copilotBuiltinAgents.js'; import { CopilotSessionWrapper, type ICopilotByokSessionConfig } from './copilotSessionWrapper.js'; @@ -247,7 +248,7 @@ export interface ICopilotSessionRuntime { handleExitPlanModeRequest(request: ExitPlanModeRequest, invocation: { sessionId: string }): Promise; handleUserInputRequest(request: UserInputRequest, invocation: UserInputInvocation): Promise; handleElicitationRequest(context: ElicitationContext): Promise; - setMcpServerDisplayNames(displayNames: ReadonlyMap): void; + setCopilotConnectorServers(connectorServers: ICopilotConnectorServers): void; handleMcpAuthRequest(request: McpAuthRequest, context: McpAuthContext): Promise; requestUnsandboxedCommandConfirmation(request: IUnsandboxedCommandConfirmationRequest): Promise; handlePreToolUse(input: PreToolUseHookInput): Promise; @@ -303,6 +304,8 @@ interface ICopilotSessionLaunchBase { * launches in {@link workingDirectory}. */ readonly additionalDirectories?: readonly URI[]; + /** Session-scoped project-extension trust inherited by a validated isolated worktree. */ + readonly projectExtensionTrustGrant?: ICopilotProjectExtensionTrustGrant; readonly resolvedAgentName: string | undefined; readonly snapshot: IActiveClientSnapshot; /** Root-configured MCP servers disabled by the owning session's resolved customization state. */ @@ -1049,8 +1052,8 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { await sandboxConfig(raw); if (this._configurationService.getRootValue(platformRootSchema, AgentHostMcpConnectorsEnabledConfigKey) === true) { const result = await reconcileCopilotConnectors(raw, this._logService); - if (result.displayNames) { - runtime.setMcpServerDisplayNames(result.displayNames); + if (result.connectorServers) { + runtime.setCopilotConnectorServers(result.connectorServers); } } await this._configureExecutionSubagentTool(raw, config); diff --git a/src/vs/platform/agentHost/node/copilot/customizations/copilotCustomizationInstallations.ts b/src/vs/platform/agentHost/node/copilot/customizations/copilotCustomizationInstallations.ts index bb0e3e8ac99ea3..20f196f0e699e8 100644 --- a/src/vs/platform/agentHost/node/copilot/customizations/copilotCustomizationInstallations.ts +++ b/src/vs/platform/agentHost/node/copilot/customizations/copilotCustomizationInstallations.ts @@ -786,6 +786,7 @@ export class CopilotCustomizationInstallations extends Disposable { description: candidate.description, publisher: candidate.publisher, itemUrl: candidate.source.kind === 'url' ? candidate.source.url : undefined, + connectorIds: candidate.connectorIds, installable: true, }; } diff --git a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts index ad911f24122b6e..988e70db759cdd 100644 --- a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts +++ b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts @@ -29,7 +29,7 @@ import { isSyntheticUserMessage } from './copilotFusionEventIdentity.js'; import { CopilotFusionMessageChunks } from './copilotFusionMessageChunks.js'; import { buildChatErrorInfoFromCopilotSdkFields } from './copilotSdkChatError.js'; import { buildMcpChannel, buildMcpTopLevelCustomizationId } from '../shared/mcpCustomizationController.js'; -import { readSimpleAttachmentDisplayKindFromMimeType } from './copilotAttachmentUtils.js'; +import { decodeTextBlobAttachmentData, normalizeBase64AttachmentData, readSimpleAttachmentDisplayKindFromMimeType } from './copilotAttachmentUtils.js'; import { buildNonPtyShellTerminalUri } from '../../common/nonPtyShellTerminalUri.js'; import { agentModelConfigurationMetaKey, IAgentRuntimeModelConfiguration } from '../../common/meta/agentModelConfigurationMeta.js'; import { buildMcpResourceUri, buildToolResultResourceUri } from '../../common/mcpResourceUri.js'; @@ -1239,7 +1239,7 @@ function sdkAttachmentToProtocol( return { type: MessageAttachmentKind.Simple, label: attachment.displayName ?? 'attachment', - modelRepresentation: decodeBase64(attachment.data ?? '').toString(), + modelRepresentation: decodeTextBlobAttachmentData(attachment.data, simpleDisplayKind !== undefined), ...(simpleDisplayKind !== undefined ? { displayKind: simpleDisplayKind } : {}), }; } @@ -1247,7 +1247,7 @@ function sdkAttachmentToProtocol( return { type: MessageAttachmentKind.EmbeddedResource, label: attachment.displayName ?? 'attachment', - data: attachment.data ?? '', + data: normalizeBase64AttachmentData(attachment.data), contentType: attachment.mimeType, displayKind, }; diff --git a/src/vs/platform/agentHost/node/missionControl/MISSION_CONTROL.md b/src/vs/platform/agentHost/node/missionControl/MISSION_CONTROL.md index af1319750da4de..c1489d19bca768 100644 --- a/src/vs/platform/agentHost/node/missionControl/MISSION_CONTROL.md +++ b/src/vs/platform/agentHost/node/missionControl/MISSION_CONTROL.md @@ -126,6 +126,8 @@ Opening cold history supplies a capture only to that restoration; observing the [The Copilot provider](../copilot/copilotAgent.ts) owns separate lazy SDK clients per scoped chat, pins each launch to its captured grant, and keeps provider-global credential changes/recovery separate from those clients. Prepared turns and SDK launches retain their captured revision. An authenticated client belonging to the same GitHub owner can steer a running turn even when its own credential differs; steering continues under the running turn's credential and does not replace it. Trusted local steering follows the same rule. This matches [Copilot Host's steering implementation](https://github.com/github/copilot-host/blob/db3e5df9d3f3c5581d0a5ce96bdbe864ce1ac518/crates/copilot-ahp-host/src/backend/session_pump.rs#L816). New turns and queued messages use their own caller's credentials at the next safe boundary. A credential change or same-principal adoption relaunches/resumes at an idle boundary without replacing the SDK backing ID. These rules do not provide complete permission isolation between same-owner devices: a steering instruction can use the existing turn's greater permissions. +Model and agent selection apply the operation's credential boundary before accessing a live runtime, including when selection precedes a new turn. Explicit local continuation can therefore replace an expired Mission Control capture with its validated local credential before resolving model policy or changing the selected agent. Active work retains its admitted credential; selection cannot replace a busy runtime with a different grant. + Scoped launches pass the captured credential as the SDK's explicit `gitHubToken`, including for one-hour token-market credentials. They do not use `gitHubTokenProvider`, whose runtime contract requires more than one hour of remaining lifetime. The host retains the actual expiry and observed-revocation checks for credential capture, SDK launch and execution admission. A client-supplied replacement takes effect at a safe boundary; static SDK authentication does not provide proactive renewal or revoke credentials already handed to running SDK operations. Clients must obtain replacement credentials and authenticate again. Local, SSH/tunnel and standalone AHP credentials keep their existing SDK token/provider selection and one-hour provider refresh margin. Credential-owned clients use the normal SDK profile with `useLoggedInUser: false`, explicit captured authentication and scrubbed ambient credential/endpoint variables. The caller's token is supplied through `GITHUB_COPILOT_GITHUB_TOKEN` for the agent shell, and through `GH_ENTERPRISE_TOKEN` when targeting GitHub Enterprise. The existing backing-data directory is retained for history continuity. Normal Git configuration and OS-user authority remain available, matching Copilot Host; this is not a sandbox against machine-owned Git credentials. Unauthenticated local metadata/deletion clients use the empty profile without becoming execution sponsors. @@ -165,6 +167,8 @@ Publishing is independently ordered per client and per `(session, namespace)` mi Queue limits include in-flight publishes: 512 frames and 64 MiB globally, with at most 128 mirror frames and 16 MiB of mirror traffic awaiting relay acceptance. A bounded reserve of 32 closure notices and 32 KiB allows an overloaded client lane to discard its unsent publications and close without disconnecting other clients. Publication failures on the shared socket still use ordinary relay recovery. 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. +A client-requested `initialize` or `reconnect` on a live relay lane replaces the previous handshake without publishing a `closed` notice: the client is already recovering, and an unsolicited close would interrupt its new handshake. Replacement still disposes the predecessor's authentication and protocol subscriptions, uses a distinct generation, and resets batch negotiation. Host-initiated connection endings continue to publish a generation-tagged `closed` notice. + Clients can advertise receive support with `{ "kind": "capabilities", "accepts": ["batch"] }` on their configured per-client `to-host` group. The host accepts this framing control only from the registered WPS owner on an existing client lane; it is not an AHP request and does not authenticate session access. Support is scoped to that lane and resets on lane replacement or host relay recovery. Clients should advertise after joining their receive groups and re-advertise after each successful initialize/reconnect response. An updated `accepts` list replaces the previous list. For supporting clients, the ordered publisher packs adjacent already-queued raw AHP messages with the same destination group and host generation into `{ "kind": "batch", "items": [...], "generation": ... }`, with at most 256 items and 900 KiB of serialized envelope bytes. It adds no batching delay. Single messages and oversized payloads retain the message/chunk framing; destination changes and closures remain ordering barriers within a client's queue, while unrelated clients and mirror streams do not interrupt its batches. Each published batch uses one WPS acknowledgement; queue limits still account for the original queued frames and bytes. diff --git a/src/vs/platform/agentHost/node/missionControl/missionControlProtocolServer.ts b/src/vs/platform/agentHost/node/missionControl/missionControlProtocolServer.ts index 296974c8841b00..b0d5545e484fd2 100644 --- a/src/vs/platform/agentHost/node/missionControl/missionControlProtocolServer.ts +++ b/src/vs/platform/agentHost/node/missionControl/missionControlProtocolServer.ts @@ -797,9 +797,7 @@ export class MissionControlProtocolServer extends Disposable implements IProtoco 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 = this._replaceLane(lane); } lane?.receive(payload); } @@ -820,14 +818,22 @@ export class MissionControlProtocolServer extends Disposable implements IProtoco this._join(`user.${this._owner}.env.${this._environment}.client.${spawn.client_id}.to-host`); } + private _replaceLane(previous: MissionControlLane): MissionControlLane { + // Detach before disposal so a client-requested replacement does not publish a closed notice. + this._lanes.deleteAndLeak(previous.clientId)?.dispose(); + const lane = this._createLane(previous.clientId, previous.passive); + this._onConnection.fire(lane); + lane.activate(); + return lane; + } + private _createLane(clientId: string, passive: boolean): MissionControlLane { const prefix = `user.${this._owner}.env.${this._environment}.client.${clientId}`; const lane: MissionControlLane = new MissionControlLane(clientId, passive, (group, payload, generation) => this._publish(group, payload, generation, lane), prefix, this._authenticationFactory?.(), (previous, message) => { if (this._closed || this._lanes.get(clientId) !== previous) { return; } - previous.dispose(); - this._receiveForClient(clientId, message); + this._replaceLane(previous).receive(message); }, ended => { if (!this._closed && this._lanes.get(clientId) === ended) { const ackId = ++this._ackId; diff --git a/src/vs/platform/agentHost/node/sessionDatabase.ts b/src/vs/platform/agentHost/node/sessionDatabase.ts index 075eaafbc460d2..2bbe4250bd8d68 100644 --- a/src/vs/platform/agentHost/node/sessionDatabase.ts +++ b/src/vs/platform/agentHost/node/sessionDatabase.ts @@ -962,6 +962,21 @@ export class SessionDatabase implements ISessionDatabase { }); } + deleteProviderTurns(providerTurnIds: readonly string[], afterHostTurnId?: string): Promise { + return this._mutateMetadataAndTurnUsage(async db => { + const foreign = await this._mayHaveForeignTurns(db); + await dbRun(db, + `DELETE FROM turns + WHERE (id IN (SELECT value FROM json_each(?1)) OR event_id IN (SELECT value FROM json_each(?1)) + OR (rowid > (SELECT rowid FROM turns WHERE id = ?2${andOwnTurn('id', foreign)}) + AND event_id = (SELECT event_id FROM turns WHERE id = ?2${andOwnTurn('id', foreign)}))) + AND id NOT IN (SELECT turn_id FROM local_turns)${andOwnTurn('id', foreign)}`, + [JSON.stringify(providerTurnIds), afterHostTurnId ?? null], + ); + await this._deleteWorkspaceTransitionMarkerIfEmpty(db); + }); + } + // ---- Host-persisted turns ------------------------------------------- insertPersistedTurn(record: IPersistedTurnRecord): Promise { @@ -1070,13 +1085,15 @@ export class SessionDatabase implements ISessionDatabase { async getFileEditsByTurn(turnId: string): Promise { const db = await this._ensureDb(); + const foreign = await this._mayHaveForeignTurns(db); const rows = await dbAll( db, `SELECT turn_id, tool_call_id, file_path, edit_type, original_path, added_lines, removed_lines FROM file_edits - WHERE turn_id = COALESCE( - (SELECT id FROM turns WHERE id = ?1 OR event_id = ?1 LIMIT 1), - ?1 + WHERE turn_id IN ( + SELECT id FROM turns WHERE (id = ?1 OR (event_id = ?1 AND NOT EXISTS ( + SELECT 1 FROM turns WHERE id = ?1${andOwnTurn('id', foreign)} + )))${andOwnTurn('id', foreign)} ) ORDER BY rowid`, [turnId], diff --git a/src/vs/platform/agentHost/node/sessionDiffAggregator.ts b/src/vs/platform/agentHost/node/sessionDiffAggregator.ts index b0d2f9016e68d6..d4fd132b614214 100644 --- a/src/vs/platform/agentHost/node/sessionDiffAggregator.ts +++ b/src/vs/platform/agentHost/node/sessionDiffAggregator.ts @@ -296,6 +296,8 @@ export async function computeSessionDiffs( * source, edits keep their insertion order. When a file is touched by more than * one source the "before" comes from the earliest source that touched it and the * "after" from the latest, which matches the shared working tree the chats edit. + * A record held by more than one source, like those a forked chat's database + * copies from its parent, counts once, in the earliest source that holds it. * * TODO (debt): this always does a full recompute — it ignores the * {@link IIncrementalDiffOptions} fast/slow paths that {@link computeSessionDiffs} @@ -317,10 +319,19 @@ export async function computeUnionedDiffs( const pathToIdentityKey = new Map(); const identities = new Map(); + // A forked chat's database starts as a copy of its parent's, so the parent's + // records can appear in more than one source. Counting a copy again would + // make it the file's last edit and hide edits the parent made after the fork. + const seenEdits = new Set(); let totalEdits = 0; for (let sourceIdx = 0; sourceIdx < perSourceEdits.length; sourceIdx++) { for (const edit of perSourceEdits[sourceIdx]) { + const editKey = `${edit.toolCallId}\0${edit.filePath}`; + if (seenEdits.has(editKey)) { + continue; + } + seenEdits.add(editKey); totalEdits++; let identityKey: string; diff --git a/src/vs/platform/agentHost/node/shared/agentServerToolHost.ts b/src/vs/platform/agentHost/node/shared/agentServerToolHost.ts index 580fbca15d4e35..ae19919cbe701e 100644 --- a/src/vs/platform/agentHost/node/shared/agentServerToolHost.ts +++ b/src/vs/platform/agentHost/node/shared/agentServerToolHost.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { equals } from '../../../../base/common/objects.js'; -import type { IAgentServerToolDefinition, IAgentServerToolHost } from '../../common/agentServerTools.js'; +import type { IAgentServerToolDefinition, IAgentServerToolHost, IServerToolConfirmationDisplay } from '../../common/agentServerTools.js'; import { ActionType } from '../../common/state/protocol/common/actions.js'; import { isAhpChatChannel, isSubagentSession, type StringOrMarkdown, type ToolDefinition, type URI } from '../../common/state/sessionState.js'; import { createDecorator } from '../../../instantiation/common/instantiation.js'; @@ -135,6 +135,9 @@ export interface IServerToolGroup { getDisplay?(toolName: string, args: unknown, result?: IServerToolDisplayResult): IServerToolDisplay | undefined; } +/** What the pure display path ({@link IServerToolGroup.getDisplay} without a host) needs of a group. */ +export type IServerToolDisplaySource = Pick; + /** * Bridges the agent host's server tools to the authoritative state tree. * Agents execute a server tool by name; the host routes it to the owning @@ -261,6 +264,15 @@ export class AgentServerToolHost implements IAgentHostServerToolService { ?? false; } + getConfirmationDisplay(toolName: string, args: unknown): IServerToolConfirmationDisplay | undefined { + const display = this._groupByToolName.get(toolName)?.getDisplay?.(this._currentToolName(toolName), args); + return display && { + ...(display.confirmationTitle !== undefined ? { confirmationTitle: display.confirmationTitle } : {}), + ...(display.confirmationMessage !== undefined ? { confirmationMessage: display.confirmationMessage } : {}), + ...(display.hideConfirmationInput !== undefined ? { hideConfirmationInput: display.hideConfirmationInput } : {}), + }; + } + executeTool(chatUri: URI, toolName: string, rawArgs: unknown): string | Promise { const group = this._groupByToolName.get(toolName); if (!group) { diff --git a/src/vs/platform/agentHost/node/shared/copilotApiService.ts b/src/vs/platform/agentHost/node/shared/copilotApiService.ts index a559221be57833..2c431adea80518 100644 --- a/src/vs/platform/agentHost/node/shared/copilotApiService.ts +++ b/src/vs/platform/agentHost/node/shared/copilotApiService.ts @@ -13,6 +13,7 @@ import { COPILOT_LICENSE_AGREEMENT } from '../../../endpoint/common/licenseAgree import { createDecorator } from '../../../instantiation/common/instantiation.js'; import { ILogService } from '../../../log/common/log.js'; import { IProductService } from '../../../product/common/productService.js'; +import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; import type { IAgentTelemetryContext } from '../../common/agent.js'; import { IAgentHostGitHubEndpointService } from '../agentHostGitHubEndpointService.js'; @@ -122,10 +123,51 @@ interface ICopilotSkuCacheCell { interface IClientRequest { promise: Promise | undefined; + discoveryGeneration: number; readonly skuCell: ICopilotSkuCacheCell; telemetryCaptured: boolean; } +type CopilotAccountDiscoveryTrigger = 'initial' | 'cacheRefresh' | 'entitlementChange'; +type CopilotAccountDiscoveryOutcome = 'published' | 'superseded' | 'failed'; + +interface ICopilotAccountDiscoveryAttempt { + readonly trigger: CopilotAccountDiscoveryTrigger; + readonly previousSku?: string; + readonly cacheAgeMs?: number; + startedAt?: number; + httpStatus?: number; + gitHubRequestId?: string; + copilotSku?: string; + copilotTrackingId?: string; +} + +type CopilotAccountDiscoveryEvent = { + trigger: CopilotAccountDiscoveryTrigger; + outcome: CopilotAccountDiscoveryOutcome; + copilotSku?: string; + previousSku?: string; + copilotTrackingId?: string; + httpStatus?: number; + gitHubRequestId?: string; + durationMs: number; + cacheAgeMs?: number; +}; + +type CopilotAccountDiscoveryClassification = { + owner: 'amunger'; + comment: 'Diagnoses Copilot account discovery freshness and endpoint responses; emitted once per actual user-endpoint lookup, not on cache hits.'; + trigger: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Initial discovery, ordinary cache refresh, or client-observed entitlement change.' }; + outcome: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether discovery was published to the cache, superseded by other discovery or invalidation, or failed.' }; + copilotSku?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Raw access_type_sku returned by this user-endpoint lookup, when available.' }; + previousSku?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Raw SKU in the previous discovery cache entry when the lookup was requested.' }; + copilotTrackingId?: { classification: 'EndUserPseudonymizedInformation'; purpose: 'BusinessInsight'; endpoint: 'GoogleAnalyticsID'; comment: 'Analytics tracking ID returned by this discovery response, without an additional hash.' }; + httpStatus?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'User-endpoint HTTP response status, absent when no response was received.' }; + gitHubRequestId?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Validated x-github-request-id response header for tracing the discovery request, when present.' }; + durationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Milliseconds from starting the user-endpoint HTTP request through discovery completion or failure, including body consumption.' }; + cacheAgeMs?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Age in milliseconds of the previous cache entry when lookup was requested; absent on initial discovery. Not the age of a model-request entitlement.' }; +}; + /** * Memoized parts of `CAPIClient` construction that don't depend on the user * token. Built once and reused by every per-token client. @@ -526,6 +568,9 @@ export interface ICopilotApiService { /** Resolve the raw Copilot entitlement SKU cached from `/copilot_internal/user`. */ resolveCopilotSku?(githubToken: string): Promise; + /** Revalidates discovery when a client observes a different SKU for the same analytics account. */ + refreshCopilotUser?(githubToken: string, trackingId: string, sku: string): Promise; + /** Read the SKU only while its account discovery cache entry remains usable. */ getCachedCopilotSku?(githubToken: string): string | undefined; @@ -539,6 +584,7 @@ export class CopilotApiService extends Disposable implements ICopilotApiService private _capiBasePromise: Promise | null = null; private readonly _clientsByToken = new Map(); + private readonly _entitlementRefreshes = new WeakMap>(); private readonly _fetch: FetchFunction; constructor( @@ -546,6 +592,7 @@ export class CopilotApiService extends Disposable implements ICopilotApiService @ILogService private readonly _logService: ILogService, @IProductService private readonly _productService: IProductService, @IAgentHostGitHubEndpointService private readonly _gitHubEndpointService: IAgentHostGitHubEndpointService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, ) { super(); this._fetch = fetchFn ?? globalThis.fetch; @@ -886,6 +933,45 @@ export class CopilotApiService extends Disposable implements ICopilotApiService return (await this._getEntryForToken(githubToken)).copilotSku; } + async refreshCopilotUser(githubToken: string, trackingId: string, sku: string): Promise { + const request = this._clientsByToken.get(githubToken); + const entry = this._readCachedClient(request?.skuCell); + if (!entry || entry.trackingId !== trackingId || entry.copilotSku === sku) { + return; + } + if (!request) { + return; + } + const existing = this._entitlementRefreshes.get(request); + if (existing) { + return existing; + } + const generation = ++request.discoveryGeneration; + const attempt = this._createDiscoveryAttempt('entitlementChange', entry); + const pending = this._buildClientForToken(githubToken, attempt).then(refreshed => { + let outcome: CopilotAccountDiscoveryOutcome = 'superseded'; + if (this._clientsByToken.get(githubToken) === request && request.skuCell.valid && request.discoveryGeneration === generation) { + request.discoveryGeneration++; + request.skuCell.value = refreshed; + outcome = 'published'; + } + this._reportDiscovery(attempt, outcome); + }).catch(error => { + this._reportDiscovery(attempt, 'failed'); + if (this._clientsByToken.get(githubToken) === request && request.skuCell.valid && request.discoveryGeneration === generation) { + request.discoveryGeneration++; + if (error instanceof CopilotApiError && (error.status === 401 || error.status === 403)) { + this._invalidateClientRequest(githubToken, request); + } else { + request.skuCell.value = undefined; + } + } + throw error; + }).finally(() => this._entitlementRefreshes.delete(request)); + this._entitlementRefreshes.set(request, pending); + return pending; + } + getCachedCopilotSku(githubToken: string): string | undefined { return this._readCopilotSku(this._clientsByToken.get(githubToken)?.skuCell); } @@ -920,15 +1006,26 @@ export class CopilotApiService extends Disposable implements ICopilotApiService // Omit the caller's signal here: a deduped build is shared across // concurrent callers, so aborting one must not cancel it for the // others. Each caller still forwards its signal to the API call. - const pending = this._buildClientForToken(githubToken).then(entry => { + const generation = request.discoveryGeneration; + const attempt = this._createDiscoveryAttempt(existing ? 'cacheRefresh' : 'initial', existing); + const pending = this._buildClientForToken(githubToken, attempt).then(entry => { + let outcome: CopilotAccountDiscoveryOutcome = 'superseded'; if (this._clientsByToken.get(githubToken) === request && request.skuCell.valid) { request.promise = undefined; - request.skuCell.value = entry; + if (request.discoveryGeneration === generation && !this._entitlementRefreshes.has(request)) { + request.skuCell.value = entry; + outcome = 'published'; + } } + this._reportDiscovery(attempt, outcome); return entry; }).catch(err => { + this._reportDiscovery(attempt, 'failed'); if (this._clientsByToken.get(githubToken) === request && request.skuCell.valid) { request.promise = undefined; + if (request.discoveryGeneration !== generation || this._entitlementRefreshes.has(request)) { + throw err; + } if (err instanceof CopilotApiError && (err.status === 401 || err.status === 403)) { this._invalidateClientRequest(githubToken, request); } else if (existing && existing.expiresAt > Date.now() / 1000) { @@ -951,6 +1048,7 @@ export class CopilotApiService extends Disposable implements ICopilotApiService if (!request) { request = { promise: undefined, + discoveryGeneration: 0, skuCell: { valid: true }, telemetryCaptured: false, }; @@ -994,7 +1092,32 @@ export class CopilotApiService extends Disposable implements ICopilotApiService this._clientsByToken.clear(); } - private async _buildClientForToken(githubToken: string): Promise { + private _createDiscoveryAttempt(trigger: CopilotAccountDiscoveryTrigger, previous: ICachedClient | undefined): ICopilotAccountDiscoveryAttempt { + return { + trigger, + previousSku: previous?.copilotSku, + cacheAgeMs: previous ? Math.max(0, Math.round(Date.now() - (previous.expiresAt - CAPI_CONTEXT_TTL_SECONDS) * 1000)) : undefined, + }; + } + + private _reportDiscovery(attempt: ICopilotAccountDiscoveryAttempt, outcome: CopilotAccountDiscoveryOutcome): void { + if (attempt.startedAt === undefined || this._store.isDisposed) { + return; + } + this._telemetryService.publicLog2('agentHost.copilotAccountDiscovery', { + trigger: attempt.trigger, + outcome, + durationMs: Math.max(0, Date.now() - attempt.startedAt), + ...(attempt.copilotSku ? { copilotSku: attempt.copilotSku } : {}), + ...(attempt.previousSku ? { previousSku: attempt.previousSku } : {}), + ...(attempt.copilotTrackingId ? { copilotTrackingId: attempt.copilotTrackingId } : {}), + ...(attempt.httpStatus !== undefined ? { httpStatus: attempt.httpStatus } : {}), + ...(attempt.gitHubRequestId ? { gitHubRequestId: attempt.gitHubRequestId } : {}), + ...(attempt.cacheAgeMs !== undefined ? { cacheAgeMs: attempt.cacheAgeMs } : {}), + }); + } + + private async _buildClientForToken(githubToken: string, attempt: ICopilotAccountDiscoveryAttempt): Promise { const userUrl = `${this._gitHubEndpointService.getApiBaseUri()}/copilot_internal/user`; const enterpriseUri = this._gitHubEndpointService.getEnterpriseUri(); const { extensionInfo } = await this._getCapiBase(); @@ -1031,6 +1154,7 @@ export class CopilotApiService extends Disposable implements ICopilotApiService this._logService.warn(`[CopilotApiService] Ignoring non-loopback CAPI URL override ${overrideApi}; falling back to normal endpoint discovery`); } + attempt.startedAt = Date.now(); const response = await this._fetch(userUrl, { method: 'GET', headers: { @@ -1039,6 +1163,11 @@ export class CopilotApiService extends Disposable implements ICopilotApiService 'X-GitHub-Api-Version': USER_API_VERSION, }, }); + attempt.httpStatus = response.status; + const gitHubRequestId = response.headers.get('x-github-request-id'); + if (gitHubRequestId && gitHubRequestId.length <= 128 && /^[a-z0-9:-]+$/i.test(gitHubRequestId)) { + attempt.gitHubRequestId = gitHubRequestId; + } if (!response.ok) { const text = await response.text().catch(() => ''); @@ -1048,6 +1177,8 @@ export class CopilotApiService extends Disposable implements ICopilotApiService const envelope: ICopilotUserResponse = await response.json(); const internalOrganization = getInternalOrg(envelope.organization_login_list); const copilotSku = typeof envelope.access_type_sku === 'string' && envelope.access_type_sku.length > 0 ? envelope.access_type_sku : undefined; + attempt.copilotSku = copilotSku; + attempt.copilotTrackingId = typeof envelope.analytics_tracking_id === 'string' && envelope.analytics_tracking_id.trim().length > 0 ? envelope.analytics_tracking_id : undefined; capiClient.updateDomains( { endpoints: envelope.endpoints ?? {}, sku: copilotSku ?? '' }, @@ -1068,7 +1199,7 @@ export class CopilotApiService extends Disposable implements ICopilotApiService apiEndpoint: envelope.endpoints?.api, copilotIgnoreEnabled: envelope.copilotignore_enabled, restrictedTelemetryEnabled: envelope.restricted_telemetry === true, - trackingId: typeof envelope.analytics_tracking_id === 'string' && envelope.analytics_tracking_id.trim().length > 0 ? envelope.analytics_tracking_id : undefined, + trackingId: attempt.copilotTrackingId, isInternal: isInternalAccount(envelope.is_staff, envelope.organization_login_list), isVscodeTeamMember: internalOrganization === 'vscode', }; diff --git a/src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts b/src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts index b5309320504117..afc656b47b0112 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, withMcpServerControllingSettingMeta, withMcpServerDisplayNameMeta, withMcpServerSourceMeta, withMcpServerSourcePluginMeta } from '../../common/meta/mcpCustomizationMeta.js'; +import { McpServerSource, readMcpServerDisplayName, withMcpServerConnectorNameMeta, 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 { isAhpChatChannel } from '../../common/state/sessionState.js'; @@ -146,6 +146,11 @@ export interface IMcpCustomizationControllerOptions { * server's current provenance. Published so clients can offer the setting without guessing from the name. */ readonly controllingSetting?: (serverName: string, provenance: IMcpServerProvenance) => string | undefined; + /** + * Returns the canonical Copilot Connector name that owns a server, when the provider knows it. Published so + * clients route the server's authentication through Connector authorization rather than an MCP token. + */ + readonly connectorName?: (serverName: string) => string | undefined; /** * MCP App capabilities to advertise on every ready server. Defaults * to {@link DEFAULT_MCP_APP_CAPABILITIES}. @@ -562,12 +567,15 @@ export class McpCustomizationController extends Disposable { // Provenance is derived from configuration on every build, never retained from earlier output. const provenance: IMcpServerProvenance = this._options.resolveProvenance?.(serverName) ?? { source: undefined }; // `SessionCustomizationUpdated` replaces the whole customization, so keep opaque entries owned by others. - const meta = withMcpServerControllingSettingMeta( - withMcpServerSourcePluginMeta( - withMcpServerDisplayNameMeta(withMcpServerSourceMeta(existing?._meta, provenance.source), displayName), - provenance.sourcePlugin, + const meta = withMcpServerConnectorNameMeta( + withMcpServerControllingSettingMeta( + withMcpServerSourcePluginMeta( + withMcpServerDisplayNameMeta(withMcpServerSourceMeta(existing?._meta, provenance.source), displayName), + provenance.sourcePlugin, + ), + this._options.controllingSetting?.(serverName, provenance), ), - this._options.controllingSetting?.(serverName, provenance), + this._options.connectorName?.(serverName), ); const uri = provenance.sourceUri ?? this._mintTopLevelId(serverName); const customization: McpServerCustomization = { diff --git a/src/vs/platform/agentHost/node/shared/serverToolGroups.ts b/src/vs/platform/agentHost/node/shared/serverToolGroups.ts index 232888ecb6ce32..ac3959b67648b9 100644 --- a/src/vs/platform/agentHost/node/shared/serverToolGroups.ts +++ b/src/vs/platform/agentHost/node/shared/serverToolGroups.ts @@ -5,10 +5,11 @@ import { feedbackServerToolGroup } from './agentFeedbackServerTools.js'; import { createSessionServerToolGroup, type ISessionServerToolAccessor } from './sessionServerTools.js'; -import type { IServerToolDisplay, IServerToolDisplayResult, IServerToolGroup } from './agentServerToolHost.js'; +import type { IServerToolDisplay, IServerToolDisplayResult, IServerToolDisplaySource, 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'; +import { createSessionMoveToolGroup, moveSessionToolDisplay } from './sessionMoveTools.js'; /** * Builds the server-tool groups contributed to every agent host session, in @@ -24,7 +25,9 @@ import { createSessionIsolationToolGroup, type ISessionIsolationToolAccessor } f * `sessionAccessor` is the runtime dependency of the session-management group * (list/create/delete sessions); it is provided by the host at construction. * When omitted (the pure display path) the session group's `execute` is inert, - * but its definitions and display remain available. + * but its definitions and display remain available. The `move_session` group + * runs only with both `sessionAccessor` and `areAgentOrchestrationLimitsEnabled`, + * as the host provides them, and is otherwise left out. */ export function buildServerToolGroups(sessionAccessor?: ISessionServerToolAccessor, agentMergeAccessor?: IAgentMergeToolAccessor, artifactAccessor?: IArtifactServerToolAccessor, areAgentOrchestrationLimitsEnabled?: () => boolean, isolationAccessor?: ISessionIsolationToolAccessor): readonly IServerToolGroup[] { return [ @@ -33,6 +36,8 @@ export function buildServerToolGroups(sessionAccessor?: ISessionServerToolAccess createAgentMergeServerToolGroup(agentMergeAccessor), createArtifactServerToolGroup(artifactAccessor), createSessionIsolationToolGroup(isolationAccessor), + // Only where it can run, which takes the host's limits too: the pure display path below displays it through `moveSessionToolDisplay`. + ...(sessionAccessor && areAgentOrchestrationLimitsEnabled ? [createSessionMoveToolGroup(sessionAccessor, areAgentOrchestrationLimitsEnabled)] : []), ]; } @@ -40,7 +45,7 @@ export function buildServerToolGroups(sessionAccessor?: ISessionServerToolAccess * The groups used by the pure {@link getServerToolDisplay} path. Built without a * session accessor since display never invokes `execute`. */ -const serverToolGroupsForDisplay: readonly IServerToolGroup[] = buildServerToolGroups(); +const serverToolGroupsForDisplay: readonly IServerToolDisplaySource[] = [...buildServerToolGroups(), moveSessionToolDisplay]; /** * Whether {@link toolName} (a tool name as seen on a tool call) refers to the diff --git a/src/vs/platform/agentHost/node/shared/sessionMcpDiscovery.ts b/src/vs/platform/agentHost/node/shared/sessionMcpDiscovery.ts index cb42ff655cb725..a6380c47b16a28 100644 --- a/src/vs/platform/agentHost/node/shared/sessionMcpDiscovery.ts +++ b/src/vs/platform/agentHost/node/shared/sessionMcpDiscovery.ts @@ -8,8 +8,9 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../base/common/map.js'; import { URI } from '../../../../base/common/uri.js'; -import { makeMcpServerCustomization, normalizeMcpServerConfiguration, readJsonFile, resolveMcpServersMap, type IMcpServerDefinition } from '../../../agentPlugins/common/pluginParsers.js'; +import { makeMcpServerCustomization, readJsonFile, resolveMcpServersMap, type IMcpServerDefinition } from '../../../agentPlugins/common/pluginParsers.js'; import type { IFileService } from '../../../files/common/files.js'; +import { fromCopilotMcpServerConfiguration } from '../../../mcp/common/mcpCopilotConfiguration.js'; class RootMcpDiscovery extends Disposable { @@ -80,7 +81,7 @@ class RootMcpDiscovery extends Disposable { return definitions; } for (const [name, value] of Object.entries(raw)) { - const configuration = normalizeMcpServerConfiguration(value); + const configuration = fromCopilotMcpServerConfiguration(value); if (configuration) { definitions.push({ name, diff --git a/src/vs/platform/agentHost/node/shared/sessionMoveTools.ts b/src/vs/platform/agentHost/node/shared/sessionMoveTools.ts new file mode 100644 index 00000000000000..f8094c300c0f3e --- /dev/null +++ b/src/vs/platform/agentHost/node/shared/sessionMoveTools.ts @@ -0,0 +1,440 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { isEqual } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import type { IAgentSessionMetadata } from '../../common/agent.js'; +import { AgentChatMoveRefusal, AgentChatMoveRefusedError } from '../../common/agentHostChatMove.js'; +import type { IAgentServerToolDefinition } from '../../common/agentServerTools.js'; +import { AGENT_HOST_SESSION_LINK_SCHEME, buildOpenSessionLinkUri, parseOpenSessionLinkChatId, parseOpenSessionLinkUri } from '../../common/openSessionLink.js'; +import { SessionServerToolName } from '../../common/serverToolNames.js'; +import { getAgentHostChatId, isAhpChatChannel, type ToolDefinition, type URI as ProtocolURI } from '../../common/state/sessionState.js'; +import type { IServerToolDisplay, IServerToolDisplayResult, IServerToolDisplaySource, IServerToolGroup } from './agentServerToolHost.js'; +import { resolveDefaultChat, resolveKnownSession, resolveLinkedChat, sessionToolRequiresConfirmation, type ISessionServerToolAccessor, type ISessionServerToolMove, type SessionServerToolMoveAnchor } from './sessionServerTools.js'; + +/** Process-wide backstop against runaway `move_session` calls, independent of the per-call user confirmation. */ +const maxMovedSessions = 50; + +const moveSessionRelationshipValues = ['nested', 'independent'] as const; +type MoveSessionRelationship = typeof moveSessionRelationshipValues[number]; + +const moveSessionInputSchema: ToolDefinition['inputSchema'] = { + type: 'object', + properties: { + session: { type: 'string', description: 'What to move: an `agent-host-session://` link or a session URI from `list_sessions`. A link carrying a chat id moves that nested session; a session without one moves the whole session, which must have no nested sessions. Omit to move the chat this tool runs in.' }, + relationship: { + type: 'string', + enum: [...moveSessionRelationshipValues], + description: 'Use `independent` to make a nested session its own top-level session; `into` and `after` must then be omitted. Otherwise omit it; defaults to `nested`.', + }, + into: { type: 'string', description: 'Required unless `relationship` is `independent`, which forbids it: the session to move it into, as a session URI from `list_sessions` or an `agent-host-session://` link. Pass its current session to reorder it there.' }, + after: { type: 'string', description: 'Only with `into`; forbidden with `independent`: an `agent-host-session://` link to a nested session of `into`, or to `into`\'s main session, to place it after. Omit to place it at the end.' }, + }, +}; + +/** The `move_session` tool, offered only while moving chats is enabled. */ +export const moveSessionToolDefinition: IAgentServerToolDefinition = { + name: SessionServerToolName.MoveSession, + title: 'Move Session', + description: 'Move a nested session or a whole session when the user asks to reorganize sessions: reorder a nested session within its session, move it into another session, make it its own session (`independent`), or move a session that has no nested sessions into another session as a nested session. Pass `into` for every move except `independent`, which takes neither `into` nor `after`. Use this only when the user asks to move or reorganize sessions; never to tidy up sessions on your own. To start new work, use `create_session` instead. A moved session keeps its history, folders and running work; both sessions must use the same agent. Get links from `list_sessions` or `get_current_session`: a session\'s `openLink`, or for a nested session the `openLink` listed for it in the session\'s `chats`. The user is asked to confirm every move. When it moves the chat this tool runs in, the move happens after this turn ends: make it your final tool call.', + inputSchema: moveSessionInputSchema, + annotations: { readOnlyHint: false }, + topLevelChatOnly: true, + deferLoading: true, +}; + +/** What a `move_session` call does, for its result and display. */ +export type MoveSessionOperation = 'reorder' | 'move' | 'promote' | 'session'; + +/** Validated `move_session` arguments, resolved against the known sessions and their recorded chats. */ +export interface IResolvedMoveSessionArgs { + readonly operation: MoveSessionOperation; + readonly move: ISessionServerToolMove; + /** The chat that moves: the named nested session, or the main session of a whole session. */ + readonly chat: URI; + /** Whether the move includes the chat this tool runs in, so it waits for the end of the turn. */ + readonly self: boolean; +} + +interface IMoveSessionArgs { + readonly session?: unknown; + readonly relationship?: unknown; + readonly into?: unknown; + readonly after?: unknown; +} + +function invalidInput(message: string): Error { + return new Error(`Invalid ${SessionServerToolName.MoveSession} input: ${message}`); +} + +function getOptionalString(value: unknown, field: string): string | undefined { + if (value === undefined) { + return undefined; + } + if (typeof value !== 'string' || value.length === 0) { + throw invalidInput(`${field} must be a non-empty string.`); + } + return value; +} + +function getRelationship(value: unknown): MoveSessionRelationship { + const relationship = getOptionalString(value, 'relationship') ?? 'nested'; + if (!(moveSessionRelationshipValues as readonly string[]).includes(relationship)) { + throw invalidInput(`relationship must be one of ${moveSessionRelationshipValues.join(', ')}.`); + } + return relationship as MoveSessionRelationship; +} + +/** Looks up the known session a session input names: a session URI or an `agent-host-session://` link. */ +export type MoveSessionLookup = (input: string) => Promise; + +/** + * A {@link MoveSessionLookup} over the session list, read once per call. It resolves links against the same data + * `list_sessions` returns, where they come from: a session that isn't loaded is listed with its chats, which a + * single-session read doesn't return. + */ +export function createMoveSessionLookup(listSessions: () => Promise): MoveSessionLookup { + let listed: Promise | undefined; + return async input => { + listed ??= listSessions(); + const sessions = await listed; + const session = resolveKnownSession(input, sessions); + return session && sessions.find(candidate => candidate.session.toString() === session.toString()); + }; +} + +/** + * Resolves `move_session` arguments. Sessions are named only by links and session URIs of known sessions; a chat only + * by a link's chat id, matched against that session's recorded chats, or as the calling chat, whose session is its + * recorded owner. A chat URI is never read for its owner. + */ +export async function getMoveSessionArgs(rawArgs: unknown, lookup: MoveSessionLookup, currentChat: ProtocolURI | undefined, getChatOwner: (chat: ProtocolURI) => ProtocolURI | undefined, getDefaultChat: (session: ProtocolURI) => ProtocolURI | undefined): Promise { + const args = (rawArgs ?? {}) as IMoveSessionArgs; + const relationship = getRelationship(args.relationship); + const sessionInput = getOptionalString(args.session, 'session'); + const intoInput = getOptionalString(args.into, 'into'); + const afterInput = getOptionalString(args.after, 'after'); + if (relationship === 'independent' && (intoInput !== undefined || afterInput !== undefined)) { + throw invalidInput('omit `into` and `after` with relationship "independent".'); + } + if (relationship === 'nested' && intoInput === undefined) { + throw invalidInput('`into` is required unless relationship is "independent".'); + } + + let session: URI; + let chat: URI; + let sessions: readonly IAgentSessionMetadata[]; + if (sessionInput !== undefined) { + const known = await lookup(sessionInput); + if (!known) { + throw invalidInput('session must be an `agent-host-session://` link or the URI of a known session (see list_sessions).'); + } + session = known.session; + sessions = [known]; + const chatId = parseOpenSessionLinkChatId(sessionInput); + const linked = chatId !== undefined ? resolveLinkedChat(sessions, session, chatId) : resolveDefaultChat(sessions, session, getDefaultChat); + if (!linked) { + throw invalidInput(chatId !== undefined ? 'session\'s chat id must name a nested session of that session (see list_sessions).' : 'the session has no main session.'); + } + chat = linked; + } else { + const owner = currentChat !== undefined ? getChatOwner(currentChat) : undefined; + if (currentChat === undefined || owner === undefined) { + throw invalidInput('session is required when this tool does not run in a chat.'); + } + session = URI.parse(owner); + chat = URI.parse(currentChat); + const known = await lookup(owner); + sessions = known ? [known] : []; + } + const defaultChat = resolveDefaultChat(sessions, session, getDefaultChat); + const wholeSession = defaultChat !== undefined && isEqual(chat, defaultChat); + // A whole session moves with every chat it has, so it includes the calling chat when that is one of them. + const self = currentChat !== undefined && (wholeSession ? getChatOwner(currentChat) === session.toString() : chat.toString() === URI.parse(currentChat).toString()); + + if (relationship === 'independent') { + if (wholeSession) { + throw invalidInput('it is already its own session. To make a nested session its own session, pass its link with its chat id.'); + } + return { operation: 'promote', move: { kind: 'chat', session, chat, destination: { kind: 'newSession' } }, chat, self }; + } + + const intoSession = await lookup(intoInput!); + if (!intoSession) { + throw invalidInput('into must be an `agent-host-session://` link or the URI of a known session (see list_sessions).'); + } + const into = intoSession.session; + let after: SessionServerToolMoveAnchor = 'end'; + if (afterInput !== undefined) { + const afterSession = await lookup(afterInput); + const afterChatId = parseOpenSessionLinkChatId(afterInput); + const anchor = afterSession && isEqual(afterSession.session, into) + ? afterChatId !== undefined ? resolveLinkedChat([intoSession], into, afterChatId) : resolveDefaultChat([intoSession], into, getDefaultChat) + : undefined; + if (!anchor) { + throw invalidInput('after must be a link to a nested session of `into`, or to its main session. Omit it to place the session at the end.'); + } + if (isEqual(anchor, chat)) { + throw invalidInput('after must name another session than the one that moves.'); + } + after = anchor; + } + if (wholeSession) { + return { operation: 'session', move: { kind: 'session', session, destination: into, after }, chat, self }; + } + return { + operation: isEqual(into, session) ? 'reorder' : 'move', + move: { kind: 'chat', session, chat, destination: { kind: 'session', session: into, after } }, + chat, + self, + }; +} + +/** One actionable sentence for the model for each reason the host refuses a move. */ +const refusalMessages: { readonly [K in AgentChatMoveRefusal]: string } = { + [AgentChatMoveRefusal.Disabled]: 'Moving sessions is turned off; tell the user it needs the `chat.agentHost.moveChats.enabled` setting.', + [AgentChatMoveRefusal.NotMovable]: 'This can\'t be moved: a main session, a subagent\'s chat or a hidden chat stays in its session; to move a whole session, pass its link without a chat id.', + [AgentChatMoveRefusal.DestinationNotFound]: 'The destination session wasn\'t found, can\'t be opened, or is the session itself; check it with `list_sessions`.', + [AgentChatMoveRefusal.InvalidAnchor]: '`after` must name another nested session or the main session of `into`; check it with `list_sessions`, or omit `after` to place it at the end.', + [AgentChatMoveRefusal.DifferentProvider]: 'Sessions only move between sessions of the same agent; pick a destination session that uses the same agent.', + [AgentChatMoveRefusal.SessionArchived]: 'One of the sessions is archived; ask the user to unarchive it first.', + [AgentChatMoveRefusal.SessionNotReady]: 'One of the sessions isn\'t ready yet; wait until its current setup or first message finishes, then try again.', + [AgentChatMoveRefusal.SessionQuarantined]: 'One of the sessions is changing its workspace or can\'t run turns; try again after that finishes.', + [AgentChatMoveRefusal.ConversionInProgress]: 'This nested session is changing its workspace; try again after that finishes.', + [AgentChatMoveRefusal.WorktreeFolder]: 'This nested session works in its own worktree that a nested session staying behind also uses; move that one too, or leave both.', + [AgentChatMoveRefusal.Workspaceless]: 'Sessions without a workspace, and nested sessions without a folder, can\'t be moved.', + [AgentChatMoveRefusal.AgentMergeOwner]: 'This runs Agent Merge for a folder of its session; ask the user to turn Agent Merge off first.', + [AgentChatMoveRefusal.DestinationSingleFolder]: 'The destination\'s agent supports one folder per session and this works in another folder; pick a destination in the same folder.', + [AgentChatMoveRefusal.LegacyCatalog]: 'One of the sessions can\'t be reorganized right now because its nested sessions can\'t be verified; try again later.', + [AgentChatMoveRefusal.Conflict]: 'The sessions changed while moving; check them with `list_sessions` and try again.', + [AgentChatMoveRefusal.Unsupported]: 'This nested session has no conversation of its own yet, so it can\'t become its own session.', + [AgentChatMoveRefusal.NotSingleChat]: 'This session has nested sessions; move them out first, or move each nested session on its own.', + [AgentChatMoveRefusal.NotStarted]: 'This nested session has no messages yet; it can become its own session after its first message.', + [AgentChatMoveRefusal.DifferentCredentialOwner]: 'Sessions with different credential owners can\'t be combined; pick a destination that uses the same account, or move it to its own session.', +}; + +/** For a whole session, `notMovable` means it isn't a session of its own anymore or couldn't be opened, not the reasons a nested session stays. */ +const sessionNotMovableMessage = 'This session can\'t be moved: it couldn\'t be opened or is no longer a session of its own; check it with `list_sessions` and try again.'; + +/** The model-facing message for a refused move; `wholeSession` when it moves a whole session. */ +export function getMoveRefusalMessage(reason: AgentChatMoveRefusal, wholeSession: boolean): string { + return `Cannot move: ${wholeSession && reason === AgentChatMoveRefusal.NotMovable ? sessionNotMovableMessage : refusalMessages[reason]}`; +} + +/** + * Turns a host refusal into the model-facing error. An anchor the tool chose itself (`end`) that is gone by the time + * the move runs means the destination changed meanwhile, not that the model passed a wrong `after`. + */ +function toMoveToolError(error: unknown, move: ISessionServerToolMove): unknown { + if (!(error instanceof AgentChatMoveRefusedError)) { + return error; + } + const after = move.kind === 'session' ? move.after : move.destination.kind === 'session' ? move.destination.after : undefined; + const reason = error.reason === AgentChatMoveRefusal.InvalidAnchor && after === 'end' ? AgentChatMoveRefusal.Conflict : error.reason; + return new Error(getMoveRefusalMessage(reason, move.kind === 'session')); +} + +const scheduledMoveResultPrefix = 'This chat will be'; + +/** Builds the model-facing `move_session` result. */ +export function formatMoveSessionResult(args: IResolvedMoveSessionArgs, session: URI): string { + switch (args.operation) { + case 'reorder': + return `Nested session reordered in its session (${buildOpenSessionLinkUri(session, getAgentHostChatId(args.chat))}).`; + case 'move': + return `Nested session moved into ${buildOpenSessionLinkUri(session)} (${buildOpenSessionLinkUri(session, getAgentHostChatId(args.chat))}).`; + case 'promote': + return `Nested session moved to its own session (${buildOpenSessionLinkUri(session)}).`; + case 'session': + return `Session moved into ${buildOpenSessionLinkUri(session)} as a nested session (${buildOpenSessionLinkUri(session, getAgentHostChatId(args.chat))}).`; + } +} + +/** Builds the model-facing result of a move of the calling chat, which waits for the end of its turn. */ +export function formatScheduledMoveSessionResult(args: IResolvedMoveSessionArgs): string { + const destination = args.move.kind === 'session' ? args.move.destination : args.move.destination.kind === 'session' ? args.move.destination.session : undefined; + const what = args.operation === 'promote' + ? 'moved to its own session' + : args.operation === 'reorder' + ? 'reordered in its session' + : `moved into ${buildOpenSessionLinkUri(destination!)}`; + return `${scheduledMoveResultPrefix} ${what} when this turn ends. Make this your final tool call: reply with one short sentence, then end the turn. Do not call ${SessionServerToolName.MoveSession} again for this chat.`; +} + +/** The operation a call's arguments describe, read from the arguments alone for display. */ +function getDisplayedOperation(args: unknown): { readonly operation: MoveSessionOperation | undefined; readonly self: boolean } { + if (args === undefined || args === null || typeof args !== 'object') { + return { operation: undefined, self: false }; + } + const input = args as IMoveSessionArgs; + const self = input.session === undefined; + if (input.relationship === 'independent') { + return { operation: 'promote', self }; + } + if (typeof input.session !== 'string') { + return { operation: undefined, self }; + } + if (parseOpenSessionLinkChatId(input.session) === undefined) { + return { operation: 'session', self }; + } + const session = parseOpenSessionLinkUri(input.session)?.toString(); + const into = typeof input.into === 'string' ? parseOpenSessionLinkUri(input.into)?.toString() ?? input.into : undefined; + return { operation: session !== undefined && session === into ? 'reorder' : 'move', self }; +} + +/** Whether the host knows the session, and with a chat id the chat of it, that a link names (see `isKnownSessionLink`). */ +type KnownSessionLinkCheck = (session: URI, chatId: string | undefined) => boolean; + +/** + * A Markdown link to the session or nested session a `move_session` argument names, labeled {@link label}. The link + * is rebuilt from the parsed session and chat id, and only for one {@link isKnown} confirms; anything else shows the + * label alone. + */ +function linkTo(label: string, input: unknown, isKnown: KnownSessionLinkCheck): string { + if (typeof input !== 'string') { + return label; + } + let link: string | undefined; + try { + const session = parseOpenSessionLinkUri(input) ?? URI.parse(input, true); + // A chat URI names no session: it is shown as the label alone. + const chatId = parseOpenSessionLinkChatId(input); + link = session.scheme && session.scheme !== AGENT_HOST_SESSION_LINK_SCHEME && !isAhpChatChannel(session.toString()) && isKnown(session, chatId) ? buildOpenSessionLinkUri(session, chatId) : undefined; + } catch { + link = undefined; + } + return link ? `[${label}](<${link.replace(/[<>]/g, encodeURIComponent)}>)` : label; +} + +function getMoveSessionDisplay(args: unknown, result: IServerToolDisplayResult | undefined, isKnown: KnownSessionLinkCheck): IServerToolDisplay { + const { operation, self } = getDisplayedOperation(args); + const input = (args !== null && typeof args === 'object' ? args : {}) as IMoveSessionArgs; + const scheduled = result?.success === true && result.text?.startsWith(scheduledMoveResultPrefix) === true; + const scheduledPastTense = localize('toolComplete.moveSession.scheduled', "Scheduled move"); + const into = linkTo(localize('toolConfirm.moveSession.destination', "the destination session"), input.into, isKnown); + if (self) { + const promote = operation === 'promote'; + return { + displayName: localize('toolName.moveSession', "Move Session"), + invocationMessage: localize('toolInvoke.moveSession.self', "Scheduling a move of this chat"), + pastTenseMessage: scheduled ? scheduledPastTense : localize('toolComplete.moveSession.self', "Processed move of this chat"), + confirmationTitle: promote ? localize('toolConfirm.moveSession.self.promote.title', "Move This Chat to Its Own Session?") : localize('toolConfirm.moveSession.self.title', "Move This Chat?"), + confirmationMessage: { + markdown: promote + ? localize('toolConfirm.moveSession.self.promote', "Make this chat its own session when this turn ends? It keeps its history and folders.") + : localize('toolConfirm.moveSession.self', "Move this chat into {0} when this turn ends? It keeps its history and folders.", into), + }, + hideConfirmationInput: true, + }; + } + const nested = linkTo(localize('toolConfirm.moveSession.nested', "this nested session"), input.session, isKnown); + switch (operation) { + case 'reorder': + return { + displayName: localize('toolName.moveSession', "Move Session"), + invocationMessage: localize('toolInvoke.moveSession.reorder', "Reordering nested session"), + pastTenseMessage: localize('toolComplete.moveSession.reorder', "Reordered nested session"), + confirmationTitle: localize('toolConfirm.moveSession.reorder.title', "Reorder Nested Session?"), + confirmationMessage: { markdown: localize('toolConfirm.moveSession.reorder', "Move {0} to a new position in its session?", nested) }, + hideConfirmationInput: true, + }; + case 'move': + return { + displayName: localize('toolName.moveSession', "Move Session"), + invocationMessage: localize('toolInvoke.moveSession.move', "Moving nested session"), + pastTenseMessage: localize('toolComplete.moveSession.move', "Moved nested session"), + confirmationTitle: localize('toolConfirm.moveSession.move.title', "Move Nested Session?"), + confirmationMessage: { markdown: localize('toolConfirm.moveSession.move', "Move {0} into {1}? It keeps its history and folders.", nested, into) }, + hideConfirmationInput: true, + }; + case 'promote': + return { + displayName: localize('toolName.moveSession', "Move Session"), + invocationMessage: localize('toolInvoke.moveSession.promote', "Moving nested session to its own session"), + pastTenseMessage: localize('toolComplete.moveSession.promote', "Moved nested session to its own session"), + confirmationTitle: localize('toolConfirm.moveSession.promote.title', "Move to Its Own Session?"), + confirmationMessage: { markdown: localize('toolConfirm.moveSession.promote', "Make {0} its own session? It keeps its history and folders.", nested) }, + hideConfirmationInput: true, + }; + case 'session': + return { + displayName: localize('toolName.moveSession', "Move Session"), + invocationMessage: localize('toolInvoke.moveSession.session', "Moving session"), + pastTenseMessage: localize('toolComplete.moveSession.session', "Moved session"), + confirmationTitle: localize('toolConfirm.moveSession.session.title', "Move Session?"), + confirmationMessage: { markdown: localize('toolConfirm.moveSession.session', "Move {0} into {1} as a nested session? It keeps its history and folders.", linkTo(localize('toolConfirm.moveSession.wholeSession', "this session"), input.session, isKnown), into) }, + hideConfirmationInput: true, + }; + default: + return { + displayName: localize('toolName.moveSession', "Move Session"), + invocationMessage: localize('toolInvoke.moveSession', "Moving session"), + pastTenseMessage: localize('toolComplete.moveSession', "Processed session move"), + confirmationTitle: localize('toolConfirm.moveSession.title', "Move Session?"), + confirmationMessage: localize('toolConfirm.moveSession', "Move this session?"), + }; + } +} + +/** + * How `move_session` is displayed where no host runs it (restored history): with nothing to check links against, it + * names sessions without links. + */ +export const moveSessionToolDisplay: IServerToolDisplaySource = { + definitions: [moveSessionToolDefinition], + getDisplay: (_toolName, args, result) => getMoveSessionDisplay(args, result, () => false), +}; + +/** + * Creates the `move_session` server-tool group. It is a group of its own, never materialized, so that its tool follows + * the `chatMoves.enabled` setting on every read: with the setting off it adds nothing to any tool list. + */ +export function createSessionMoveToolGroup(accessor: ISessionServerToolAccessor, areAgentOrchestrationLimitsEnabled: () => boolean): IServerToolGroup { + let movedSessionCount = 0; + return { + definitions: moveSessionToolDisplay.definitions, + // Confirmations link only sessions and chats the host knows. + getDisplay: (_toolName, args, result) => getMoveSessionDisplay(args, result, (session, chatId) => accessor.isKnownSessionLink(session, chatId)), + isEnabled: toolName => toolName === SessionServerToolName.MoveSession && accessor.isChatMoveEnabled(), + isEnabledForSession: (_toolName, sessionUri) => accessor.supportsChatMoves(URI.parse(sessionUri)), + canRequireConfirmation: toolName => sessionToolRequiresConfirmation(toolName), + async execute(_stateManager, context, _toolName, rawArgs): Promise { + if (!accessor.isChatMoveEnabled()) { + throw new Error(getMoveRefusalMessage(AgentChatMoveRefusal.Disabled, false)); + } + if (areAgentOrchestrationLimitsEnabled() && movedSessionCount >= maxMovedSessions) { + throw new Error(`Refusing to move more than ${maxMovedSessions} sessions from server tools in this process.`); + } + // Reserved before any await, so concurrent calls can't pass the limit together; released when the move doesn't happen. + movedSessionCount++; + let moved = false; + try { + const currentChat = isAhpChatChannel(context.chatUri) ? context.chatUri : undefined; + const args = await getMoveSessionArgs(rawArgs, createMoveSessionLookup(() => accessor.listSessions()), currentChat, chat => accessor.getChatOwner(chat), session => accessor.getDefaultChat(session)); + try { + if (args.self) { + if (!context.turnId) { + throw new Error(`${SessionServerToolName.MoveSession} must run from an active chat turn to move the chat it runs in.`); + } + await accessor.checkChatMove(args.move); + accessor.scheduleChatMove(currentChat!, context.turnId, args.move); + moved = true; + return formatScheduledMoveSessionResult(args); + } + const session = await accessor.applyChatMove(args.move); + moved = true; + return formatMoveSessionResult(args, session); + } catch (error) { + throw toMoveToolError(error, args.move); + } + } finally { + if (!moved) { + movedSessionCount--; + } + } + }, + }; +} diff --git a/src/vs/platform/agentHost/node/shared/sessionServerTools.ts b/src/vs/platform/agentHost/node/shared/sessionServerTools.ts index 50626f0ccbd857..257ef6554a9486 100644 --- a/src/vs/platform/agentHost/node/shared/sessionServerTools.ts +++ b/src/vs/platform/agentHost/node/shared/sessionServerTools.ts @@ -14,8 +14,8 @@ import { AgentSession, type AgentProvider, type IAgentCreateSessionConfig, type import { SessionStatus } from '../../common/state/protocol/channels-session/state.js'; import { ActionType } from '../../common/state/sessionActions.js'; import type { IAgentServerToolDefinition } from '../../common/agentServerTools.js'; -import { getAgentHostChatId, getInlineToolInput, getSessionRelatedPullRequestUrls, isAhpChatChannel, isSessionStatusArchived, isSessionStatusRead, MessageKind, PendingMessageKind, readSessionGitState, readSessionGitHubState, getAllSessionRelatedPullRequestUrls, readSessionWorkspaceless, ResponsePartKind, ToolCallStatus, TurnState, withSessionCreationReference, type Message, type ModelSelection, type PendingMessage, type ResponsePart, type ToolCallState, type ToolDefinition, type Turn, type URI as ProtocolURI } from '../../common/state/sessionState.js'; -import { buildOpenSessionLinkUri, parseOpenSessionLinkChatId, parseOpenSessionLinkUri } from '../../common/openSessionLink.js'; +import { ChatInteractivity, ChatOriginKind, getAgentHostChatId, getInlineToolInput, getSessionRelatedPullRequestUrls, isAhpChatChannel, isSessionStatusArchived, isSessionStatusRead, MessageKind, PendingMessageKind, readSessionGitState, readSessionGitHubState, getAllSessionRelatedPullRequestUrls, readSessionWorkspaceless, ResponsePartKind, ToolCallStatus, TurnState, withSessionCreationReference, type Message, type ModelSelection, type PendingMessage, type ResponsePart, type ToolCallState, type ToolDefinition, type Turn, type URI as ProtocolURI } from '../../common/state/sessionState.js'; +import { buildOpenSessionLinkForChat, buildOpenSessionLinkUri, parseOpenSessionLinkChatId, parseOpenSessionLinkUri } from '../../common/openSessionLink.js'; import { SessionServerToolName } from '../../common/serverToolNames.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import type { IAgentHostCredentialSource } from '../../common/agentHostCredentials.js'; @@ -41,7 +41,7 @@ const maxCreatedChats = 50; /** Process-wide backstop against runaway `send_message` fan-out. */ const maxSentMessages = 100; -const sessionConfirmationToolNames: ReadonlySet = new Set([SessionServerToolName.SetWorkspace, SessionServerToolName.CreateSession, SessionServerToolName.CreateChat, SessionServerToolName.SendMessage, SessionServerToolName.DeleteSession]); +const sessionConfirmationToolNames: ReadonlySet = new Set([SessionServerToolName.SetWorkspace, SessionServerToolName.CreateSession, SessionServerToolName.CreateChat, SessionServerToolName.SendMessage, SessionServerToolName.DeleteSession, SessionServerToolName.MoveSession]); const createSessionRelationshipValues = ['currentSession', 'independent'] as const; export type CreateSessionRelationship = typeof createSessionRelationshipValues[number]; @@ -113,6 +113,16 @@ const createSessionWithSharedWorkspaceInputSchema: ToolDefinition['inputSchema'] const createSessionWithSharedWorkspaceDescription = '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`.'; +/** The sentences of `list_sessions` and `get_current_session` that describe a session's `chats`, by tool. */ +const listSessionsChatsSentence = 'A session with nested sessions also lists `chats`: its main session (`main: true`) and its nested sessions in order, each with a `title` and an `openLink` that names that chat in `send_message`, `get_session_context` and `rename_chat`.'; +const getCurrentSessionChatsSentence = 'Get metadata and the open link for the session this conversation is running in, with its nested sessions in `chats` like `list_sessions`.'; +const sessionChatsSentences: ReadonlyMap = new Map([ + [SessionServerToolName.ListSessions, listSessionsChatsSentence], + [SessionServerToolName.GetCurrentSession, getCurrentSessionChatsSentence], +]); +/** Follows a tool's {@link sessionChatsSentences} entry while `move_session` is offered. */ +const moveSessionLinksSentence = ' `move_session` takes these links too.'; + const getCurrentSessionInputSchema: ToolDefinition['inputSchema'] = { type: 'object', properties: {}, @@ -189,7 +199,7 @@ export const sessionServerToolDefinitions: IAgentServerToolDefinition[] = [ { name: SessionServerToolName.ListSessions, title: 'List Sessions', - description: 'List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Each result includes `session` for identity and tool inputs and `openLink` for clickable Markdown links; do not use `session` as a link target. Pass `session` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.', + description: `List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Each result includes \`session\` for identity and tool inputs and \`openLink\` for clickable Markdown links; do not use \`session\` as a link target. ${listSessionsChatsSentence} Pass \`session\` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by \`status\`, \`workspace\`, \`withChanges\`, \`unread\`, \`withPullRequest\`, \`includeArchived\`, \`createdAfter\`, or \`createdBefore\`.`, inputSchema: listSessionsInputSchema, annotations: { readOnlyHint: true }, deferLoading: true, @@ -197,7 +207,7 @@ export const sessionServerToolDefinitions: IAgentServerToolDefinition[] = [ { name: SessionServerToolName.GetCurrentSession, title: 'Get Current Session', - description: 'Get metadata and the open link for the session this conversation is running in. Use this to reference the current session (for example before adding a chat to it).', + description: `${getCurrentSessionChatsSentence} Use this to reference the current session or one of its nested sessions (for example before adding a chat to it).`, inputSchema: getCurrentSessionInputSchema, annotations: { readOnlyHint: true }, deferLoading: true, @@ -343,12 +353,53 @@ export interface IAgentServiceSessionServerToolAccessor { readonly getSessionSpawnDepth: (session: URI) => number; /** Records the spawn depth of a freshly-created session so its own `create_session` calls can enforce the recursion limit. */ readonly setSessionSpawnDepth: (session: URI, depth: number) => void; + /** Whether moving chats is enabled (`chatMoves.enabled`), read on every call. */ + readonly isChatMoveEnabled: () => boolean; + /** Whether the session's agent supports more than one chat per session, so its chats can move. */ + readonly supportsChatMoves: (session: URI) => boolean; + /** + * Refuses, with an `AgentChatMoveRefusedError`, a move the host would refuse now, without changing anything. + * The move validates everything again when it is applied. + */ + readonly checkChatMove: (move: ISessionServerToolMove) => Promise; + /** Applies a move through the same host operations as AHP `moveChat` and `vscode/moveSession`; returns the session it is in now. */ + readonly applyChatMove: (move: ISessionServerToolMove) => Promise; + /** + * Whether the host knows `session` (loaded or listed), and with `chatId` a chat of it by that id among its recorded + * chats. Synchronous, from the host's state, for display only. + */ + readonly isKnownSessionLink: (session: URI, chatId: string | undefined) => boolean; } +/** + * Where `move_session` places a chat in its destination session: after a chat of + * that session, or after its last visible chat (`end`). + */ +export type SessionServerToolMoveAnchor = URI | 'end'; + +/** A move requested by `move_session`. */ +export type ISessionServerToolMove = + /** A nested session moves within its session, into another session, or to a new session of its own. */ + | { + readonly kind: 'chat'; + readonly session: URI; + readonly chat: URI; + readonly destination: { readonly kind: 'session'; readonly session: URI; readonly after: SessionServerToolMoveAnchor } | { readonly kind: 'newSession' }; + } + /** A session whose main session is its only chat moves into another session as a nested session. */ + | { + readonly kind: 'session'; + readonly session: URI; + readonly destination: URI; + readonly after: SessionServerToolMoveAnchor; + }; + /** Complete dependency surface needed by the session server-tool group. */ export interface ISessionServerToolAccessor extends IAgentServiceSessionServerToolAccessor { /** Returns whether a workspace change was scheduled. */ readonly requestSessionWorkspaceUpdate: (chat: URI, turnId: string, workspaceFolder: URI, isolation: boolean, credentials?: IAgentHostCredentialSource) => boolean; + /** Schedules a move of the calling chat for when its turn {@link turnId} ends successfully. */ + readonly scheduleChatMove: (chat: ProtocolURI, turnId: string, move: ISessionServerToolMove) => void; /** The recorded owning session of a registered chat. */ readonly getChatOwner: (chat: ProtocolURI) => ProtocolURI | undefined; /** The recorded default chat of a session, if any. */ @@ -356,7 +407,7 @@ export interface ISessionServerToolAccessor extends IAgentServiceSessionServerTo } /** Resolves the chat a link's chat id addresses against the session's recorded chats. */ -function resolveLinkedChat(sessions: readonly IAgentSessionMetadata[], session: URI, chatId: string): URI | undefined { +export function resolveLinkedChat(sessions: readonly IAgentSessionMetadata[], session: URI, chatId: string): URI | undefined { const chats = sessions.find(candidate => candidate.session.toString() === session.toString())?.chats ?? []; return (chats.find(entry => entry.chat.toString() === chatId) ?? chats.find(entry => getAgentHostChatId(entry.chat) === chatId))?.chat; } @@ -366,7 +417,7 @@ function resolveLinkedChat(sessions: readonly IAgentSessionMetadata[], session: * else, for a session that records none, the default chat its listing names. * `undefined` when neither names one. */ -function resolveDefaultChat(sessions: readonly IAgentSessionMetadata[], session: URI, getDefaultChat: (session: ProtocolURI) => ProtocolURI | undefined): URI | undefined { +export function resolveDefaultChat(sessions: readonly IAgentSessionMetadata[], session: URI, getDefaultChat: (session: ProtocolURI) => ProtocolURI | undefined): URI | undefined { const defaultChat = getDefaultChat(session.toString()) ?? sessions.find(candidate => candidate.session.toString() === session.toString())?.chats?.find(chat => chat.kind === 'default')?.chat.toString(); return defaultChat === undefined ? undefined : URI.parse(defaultChat); @@ -435,6 +486,40 @@ interface ISerializedSession { readonly changes?: IAgentSessionMetadata['changes']; readonly git?: ISerializedGitState; readonly github?: ISerializedGitHubState; + /** The session's main session and nested sessions, in order; only when it has nested sessions. */ + readonly chats?: readonly ISerializedSessionChat[]; +} + +interface ISerializedSessionChat { + readonly title?: string; + /** Clickable link naming this chat in its session: the session's own link for its main session. */ + readonly openLink: string; + /** Whether this is the session's main session. */ + readonly main?: true; + readonly status?: string; +} + +/** + * The main session and nested sessions of a session as listed, in their order; `undefined` when it has no nested + * sessions. Chats of subagents and hidden chats are not sessions of their own and are left out. + */ +function serializeSessionChats(session: IAgentSessionMetadata): readonly ISerializedSessionChat[] | undefined { + const chats = (session.chats ?? []).filter(chat => chat.origin?.kind !== ChatOriginKind.Tool && chat.interactivity !== ChatInteractivity.Hidden); + if (!chats.some(chat => chat.kind !== 'default')) { + return undefined; + } + const defaultChat = chats.find(chat => chat.kind === 'default')?.chat; + return chats.map(chat => { + const main = chat.kind === 'default'; + const title = chat.summary ?? (main ? session.summary : undefined); + const status = chat.status !== undefined ? describeSessionStatusBits(chat.status).join(',') || undefined : undefined; + return { + ...(title !== undefined ? { title } : {}), + openLink: buildOpenSessionLinkForChat(session.session, chat.chat, defaultChat, undefined) ?? buildOpenSessionLinkUri(session.session), + ...(main ? { main: true as const } : {}), + ...(status !== undefined ? { status } : {}), + }; + }); } function getRequiredString(value: unknown, field: string, toolName: string): string { @@ -890,6 +975,7 @@ function serializeSession(session: IAgentSessionMetadata): ISerializedSession { const git = serializeGitState(session); const github = serializeGitHubState(session); const status = describeSessionStatus(session); + const chats = serializeSessionChats(session); return { session: session.session.toString(), openLink: buildOpenSessionLinkUri(session.session), @@ -908,6 +994,7 @@ function serializeSession(session: IAgentSessionMetadata): ISerializedSession { ...(session.changes !== undefined ? { changes: session.changes } : {}), ...(git !== undefined ? { git } : {}), ...(github !== undefined ? { github } : {}), + ...(chats !== undefined ? { chats } : {}), }; } @@ -1072,7 +1159,7 @@ interface IResolvedCreateChatArgs { * link (as returned by `create_session`/`get_current_session`) — against the * set of known sessions. Returns `undefined` when it matches no known session. */ -function resolveKnownSession(sessionInput: string, sessions: readonly IAgentSessionMetadata[]): URI | undefined { +export function resolveKnownSession(sessionInput: string, sessions: readonly IAgentSessionMetadata[]): URI | undefined { // Normalize an open-session link back to its backend session URI. const fromLink = parseOpenSessionLinkUri(sessionInput); const candidate = fromLink?.toString() ?? sessionInput; @@ -1888,6 +1975,12 @@ export function createSessionServerToolGroup(accessor?: ISessionServerToolAccess return toolName !== SessionServerToolName.RenameChat || accessor?.getAutomaticTitleGenerationStrategy(sessionUri) !== 'utility'; }, getDefinitionForSession(definition, sessionUri): IAgentServerToolDefinition { + // Mentions `move_session` only while it is offered (`chatMoves.enabled`): with moves off, these read as before. + // Also only where its session's agent can move chats, as `move_session` itself. + if ((definition.name === SessionServerToolName.ListSessions || definition.name === SessionServerToolName.GetCurrentSession) && accessor?.isChatMoveEnabled() === true && accessor.supportsChatMoves(URI.parse(sessionUri))) { + const chatsSentence = sessionChatsSentences.get(definition.name)!; + return { ...definition, description: definition.description?.replace(chatsSentence, `${chatsSentence}${moveSessionLinksSentence}`) }; + } if (definition.name === SessionServerToolName.SetWorkspace && accessor?.isWorkspaceless(URI.parse(sessionUri)) === true) { return { ...definition, deferLoading: false }; } diff --git a/src/vs/platform/agentHost/test/common/agentEntitlementHintMeta.test.ts b/src/vs/platform/agentHost/test/common/agentEntitlementHintMeta.test.ts new file mode 100644 index 00000000000000..49336b1de8af41 --- /dev/null +++ b/src/vs/platform/agentHost/test/common/agentEntitlementHintMeta.test.ts @@ -0,0 +1,22 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { agentEntitlementHintMeta, readAgentEntitlementHint } from '../../common/meta/agentEntitlementHintMeta.js'; + +suite('Agent entitlement hint metadata', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('round-trips a hint and ignores absent or malformed extensions', () => { + const entitlement = { trackingId: 'analytics', sku: 'free_limited_copilot' }; + const invalid = [undefined, null, [], '', {}, { trackingId: ' ', sku: 'free' }, { trackingId: 'a', sku: 42 }]; + assert.deepStrictEqual({ + valid: readAgentEntitlementHint({ _meta: agentEntitlementHintMeta(entitlement) }), + absent: readAgentEntitlementHint({}), + invalid: invalid.map(value => readAgentEntitlementHint({ _meta: { 'vscode.authentication.entitlement': value } })), + }, { valid: entitlement, absent: undefined, invalid: invalid.map(() => undefined) }); + }); +}); diff --git a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts index 98c06289d8e951..21a55dc8e9d87a 100644 --- a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts +++ b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts @@ -423,6 +423,21 @@ export class TestSessionDatabase implements ISessionDatabase { this._toolResultResources.clear(); } + async deleteProviderTurns(providerTurnIds: readonly string[], afterHostTurnId?: string): Promise { + const ids = new Set(providerTurnIds); + const boundaryEvent = afterHostTurnId ? this._turnEventIds.get(afterHostTurnId) : undefined; + let afterBoundary = false; + for (const turnId of this._turns) { + const eventId = this._turnEventIds.get(turnId); + if (!this._persistedTurns.has(turnId) && (ids.has(turnId) || ids.has(eventId ?? '') || (afterBoundary && boundaryEvent !== undefined && eventId === boundaryEvent))) { + await this.deleteTurn(turnId); + } + if (turnId === afterHostTurnId) { + afterBoundary = true; + } + } + } + async insertPersistedTurn(record: IPersistedTurnRecord): Promise { if (record.kind === 'failed') { this._turns.add(record.turnId); 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 6e50f71ed30133..d57be6e92f53a2 100644 --- a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts @@ -12,6 +12,7 @@ import { CancellationError, getErrorCode, transformErrorForSerialization, transf import { IPolicyData } from '../../../../base/common/defaultAccount.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../base/common/network.js'; import { observableValue } from '../../../../base/common/observable.js'; import { extUriBiasedIgnorePathCase } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; @@ -26,7 +27,7 @@ import { McpAuthRequiredReason } from '../../common/state/protocol/channels-sess import { AgentPolicyError, AuthenticateParams } from '../../common/agent.js'; import { authenticationAccountMeta } from '../../common/meta/agentAuthenticationAccount.js'; import { readAgentSandboxCertificate, withAgentSandboxCertificate, type IAgentSandboxCertificateStatus } from '../../common/meta/agentSandboxCertificate.js'; -import { agentHostAuthority, toAgentHostUri } from '../../common/agentHostUri.js'; +import { AGENT_HOST_SCHEME, 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'; @@ -132,6 +133,12 @@ class TestWorkspaceTrustManagementService extends mock(); + override isWorkspaceTrusted(): boolean { return this.workspaceTrusted; } + override isWorkspaceTrustForced(): boolean { return this.workspaceTrustForced; } + override isAuthorityTrusted(scheme: string, authority: string): boolean { return this.trustedAuthorities.has(`${scheme}://${authority}`); } override getTrustedUris(): URI[] { return this.trustedUris; } } @@ -1038,6 +1045,61 @@ suite('AgentHostProtocolClient', () => { ]); }); + test('workspace trust forwards effective remote authority trust', async () => { + const transport = disposables.add(new TestProtocolTransport()); + const trustService = new TestWorkspaceTrustManagementService(); + trustService.trustedAuthorities.add(`${Schemas.vscodeRemote}://ssh-remote+test`); + const client = disposables.add(new AgentHostProtocolClient('vscode-remote://ssh-remote+test', transport, undefined, new NullLogService(), createPermissionService(), new TestConfigurationService(), NullTelemetryService, workspaceTrustEnablementService, trustService, createWorkspaceTrustServices().request, defaultAccountService)); + await connectClient(client, transport); + assert.deepStrictEqual(findRootConfigValue(transport.sentMessages, AgentHostWorkspaceTrustConfigKey), { + enabled: true, + trustedUris: [], + connectionAuthorityTrusted: true, + }); + }); + + test('workspace trust forwards registered cloud sandbox authority trust', async () => { + const transport = disposables.add(new TestProtocolTransport()); + const trustService = new TestWorkspaceTrustManagementService(); + const identity = 'cloudsandbox:test'; + trustService.trustedAuthorities.add(`${AGENT_HOST_SCHEME}://${agentHostAuthority(identity)}`); + const client = disposables.add(new AgentHostProtocolClient(identity, transport, undefined, new NullLogService(), createPermissionService(), new TestConfigurationService(), NullTelemetryService, workspaceTrustEnablementService, trustService, createWorkspaceTrustServices().request, defaultAccountService)); + await connectClient(client, transport); + assert.deepStrictEqual(findRootConfigValue(transport.sentMessages, AgentHostWorkspaceTrustConfigKey), { + enabled: true, + trustedUris: [], + connectionAuthorityTrusted: true, + }); + }); + + test('workspace trust does not infer remote authority trust from an empty trusted workspace', async () => { + const transport = disposables.add(new TestProtocolTransport()); + const trustService = new TestWorkspaceTrustManagementService(); + trustService.workspaceTrusted = true; + trustService.workspaceTrustForced = true; + const client = disposables.add(new AgentHostProtocolClient('vscode-remote://ssh-remote+test', transport, undefined, new NullLogService(), createPermissionService(), new TestConfigurationService(), NullTelemetryService, workspaceTrustEnablementService, trustService, createWorkspaceTrustServices().request, defaultAccountService)); + await connectClient(client, transport); + assert.deepStrictEqual(findRootConfigValue(transport.sentMessages, AgentHostWorkspaceTrustConfigKey), { + enabled: true, + trustedUris: [], + }); + }); + + test('workspace trust treats an absent authority query as untrusted', async () => { + const transport = disposables.add(new TestProtocolTransport()); + const trustService = new class extends mock() { + override onDidChangeTrust = Event.None; + override onDidChangeTrustedFolders = Event.None; + override getTrustedUris(): URI[] { return []; } + }(); + const client = disposables.add(new AgentHostProtocolClient('cloudsandbox:test', transport, undefined, new NullLogService(), createPermissionService(), new TestConfigurationService(), NullTelemetryService, workspaceTrustEnablementService, trustService, createWorkspaceTrustServices().request, defaultAccountService)); + await connectClient(client, transport); + assert.deepStrictEqual(findRootConfigValue(transport.sentMessages, AgentHostWorkspaceTrustConfigKey), { + enabled: true, + trustedUris: [], + }); + }); + test('workspace trust forwards explicit disablement from the enablement service', async () => { const transport = disposables.add(new TestProtocolTransport()); const client = disposables.add(new AgentHostProtocolClient(LOCAL_AGENT_HOST_RESOURCE_IDENTITY, transport, undefined, new NullLogService(), createPermissionService(), new TestConfigurationService(), NullTelemetryService, { _serviceBrand: undefined, isWorkspaceTrustEnabled: () => false }, new TestWorkspaceTrustManagementService(), createWorkspaceTrustServices().request, defaultAccountService)); diff --git a/src/vs/platform/agentHost/test/node/__snapshots__/Copilot_SDK_-_managed_settings_bundled_managedSettings_schema_matches_the_snapshot.schema.json b/src/vs/platform/agentHost/test/node/__snapshots__/Copilot_SDK_-_managed_settings_bundled_managedSettings_schema_matches_the_snapshot.schema.json index 5c769bdee9a7b5..2b90bfb289c698 100644 --- a/src/vs/platform/agentHost/test/node/__snapshots__/Copilot_SDK_-_managed_settings_bundled_managedSettings_schema_matches_the_snapshot.schema.json +++ b/src/vs/platform/agentHost/test/node/__snapshots__/Copilot_SDK_-_managed_settings_bundled_managedSettings_schema_matches_the_snapshot.schema.json @@ -1,1304 +1,1301 @@ { - "schema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/schemas/copilot/managed-settings/v0", - "title": "Managed Settings for Copilot Clients", - "description": "Schema for managed settings delivered via MDM (plist/registry), file-based (managed-settings.json), or server-managed (/copilot_internal/managed_settings). All keys are optional — an empty document or a document with only a subset of keys is valid.", - "type": "object", - "additionalProperties": false, - "properties": { - "allowedMcpServers": { - "description": "Managed allowlist of MCP servers users may load, aligned with Claude Code semantics. Each entry identifies servers by exactly one of serverUrl (URL with '*' wildcards), serverCommand (exact command+args), or serverName (literal label). Governs only non-default servers: trusted first-party servers (e.g. the built-in GitHub MCP server) are always exempt. Unset means all (non-default) servers are allowed; an empty array admits none of them; a populated list admits only matching ones. For remote (HTTP/SSE) servers the match must be a serverUrl entry (serverName counts only when no serverUrl entries exist); for stdio servers the match must be a serverCommand entry (serverName counts only when no serverCommand entries exist). Denied entries always win over allowed entries.", - "type": "array", - "items": { - "$ref": "#/$defs/mcpAllowMatcher" - }, - "x-composition": { - "strategy": "allowlist-intersection" - } + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/schemas/copilot/managed-settings/v0", + "title": "Managed Settings for Copilot Clients", + "description": "Schema for managed settings delivered via MDM (plist/registry), file-based (managed-settings.json), or server-managed (/copilot_internal/managed_settings). All keys are optional — an empty document or a document with only a subset of keys is valid.", + "type": "object", + "additionalProperties": false, + "properties": { + "allowedMcpServers": { + "description": "Managed allowlist of MCP servers users may load, aligned with Claude Code semantics. Each entry identifies servers by exactly one of serverUrl (URL with '*' wildcards), serverCommand (exact command+args), or serverName (literal label). Governs only non-default servers: trusted first-party servers (e.g. the built-in GitHub MCP server) are always exempt. Unset means all (non-default) servers are allowed; an empty array admits none of them; a populated list admits only matching ones. For remote (HTTP/SSE) servers the match must be a serverUrl entry (serverName counts only when no serverUrl entries exist); for stdio servers the match must be a serverCommand entry (serverName counts only when no serverCommand entries exist). Denied entries always win over allowed entries.", + "type": "array", + "items": { + "$ref": "#/$defs/mcpAllowMatcher" + }, + "x-composition": { + "strategy": "allowlist-intersection" + } + }, + "deniedMcpServers": { + "description": "Managed denylist of MCP servers that must never load, aligned with Claude Code semantics. Each entry identifies servers by exactly one of serverUrl (URL with '*' wildcards), serverCommand (exact command+args), or serverName (literal label). A matching non-default server is blocked regardless of the allowlist. Trusted first-party servers (e.g. the built-in GitHub MCP server) are exempt and cannot be blocked by a deny entry. Unset or an empty array blocks nothing.", + "type": "array", + "items": { + "$ref": "#/$defs/mcpDenyMatcher" }, - "deniedMcpServers": { - "description": "Managed denylist of MCP servers that must never load, aligned with Claude Code semantics. Each entry identifies servers by exactly one of serverUrl (URL with '*' wildcards), serverCommand (exact command+args), or serverName (literal label). A matching non-default server is blocked regardless of the allowlist. Trusted first-party servers (e.g. the built-in GitHub MCP server) are exempt and cannot be blocked by a deny entry. Unset or an empty array blocks nothing.", - "type": "array", - "items": { - "$ref": "#/$defs/mcpDenyMatcher" + "x-composition": { + "strategy": "deny-wins" + } + }, + "strictPluginOnlyCustomization": { + "description": "Customization lockdown baseline. When set, prevents standalone user and workspace/project customizations from the named surfaces; those customizations may then originate only from plugins or managed settings. It does NOT mean managed-plugin-only — eligible plugin-provided customizations are still permitted (use `allowManagedMcpServersOnly` / `allowManagedHooksOnly` for the stricter managed-only overlays). `true` locks all four surfaces (skills, agents, hooks, MCP servers); an array locks only the named surfaces. Unset preserves existing behavior.", + "oneOf": [ + { + "description": "true locks all four surfaces (skills, agents, hooks, MCP servers); false preserves existing behavior.", + "type": "boolean" }, - "x-composition": { - "strategy": "deny-wins" + { + "description": "Locks only the named customization surfaces.", + "type": "array", + "items": { + "type": "string", + "enum": [ + "skills", + "agents", + "hooks", + "mcp" + ] + }, + "uniqueItems": true + } + ], + "x-composition": { + "strategy": "most-restrictive-wins", + "description": "Union of locked surfaces across managed sources: a surface locked by any higher-priority managed source stays locked; the boolean true form is equivalent to locking every surface." + } + }, + "allowManagedMcpServersOnly": { + "description": "Stricter overlay on top of `strictPluginOnlyCustomization`: when true, only the managed `allowedMcpServers` allowlist (plus MCP servers provided by managed/force-enabled plugins and any fixed managed-MCP deployment) governs which MCP servers are eligible. Lower-precedence user and workspace/project allowlist entries cannot broaden it; `deniedMcpServers` retains its restrictive deny-wins merge. Trusted first-party servers (e.g. the built-in GitHub MCP server) remain exempt. Unset or false preserves existing behavior.", + "type": "boolean", + "x-composition": { + "strategy": "most-restrictive-wins", + "description": "Sticky-true: a lower-priority source cannot undo allowManagedMcpServersOnly:true set by a higher-priority managed source." + } + }, + "allowManagedHooksOnly": { + "description": "Stricter overlay on top of `strictPluginOnlyCustomization`: when true, only enterprise-managed hooks and hooks supplied by plugins force-enabled through managed `enabledPlugins` may load. User, workspace/project, and otherwise user-enabled plugin hooks are blocked. This does not silently grant additional tool or network access; normal managed permission and sandbox policies still apply. Unset or false preserves existing behavior.", + "type": "boolean", + "x-composition": { + "strategy": "most-restrictive-wins", + "description": "Sticky-true: a lower-priority source cannot undo allowManagedHooksOnly:true set by a higher-priority managed source." + } + }, + "features": { + "description": "Open map of managed product feature availability. A false value blocks the named feature; true or absent does not force-enable it.", + "type": "object", + "additionalProperties": { + "type": "boolean" + } + }, + "vscode": { + "description": "VS Code-specific managed preferences. Preserved by the shared runtime for VS Code to consume; the runtime and other clients do not interpret these preferences. Delivered through server-managed or file-based settings, not native MDM.", + "type": "object", + "additionalProperties": false, + "x-mdm-delivery": false, + "properties": { + "temporaryDefaultToLocalHarness": { + "description": "Temporary escape hatch: true defaults new VS Code chats to the Local harness without disabling telemetry or preventing users from choosing the Copilot harness. False or absent restores normal rollout behavior. This preference must not override governance requiring the Copilot harness, including enforced sandboxing and runtime-owned permission policies.", + "type": "boolean" } }, - "strictPluginOnlyCustomization": { - "description": "Customization lockdown baseline. When set, prevents standalone user and workspace/project customizations from the named surfaces; those customizations may then originate only from plugins or managed settings. It does NOT mean managed-plugin-only — eligible plugin-provided customizations are still permitted (use `allowManagedMcpServersOnly` / `allowManagedHooksOnly` for the stricter managed-only overlays). `true` locks all four surfaces (skills, agents, hooks, MCP servers); an array locks only the named surfaces. Unset preserves existing behavior.", - "oneOf": [ - { - "description": "true locks all four surfaces (skills, agents, hooks, MCP servers); false preserves existing behavior.", - "type": "boolean" + "x-composition": { + "strategy": "highest-layer-wins", + "description": "The whole vscode block is selected from the highest-priority channel that supplies it: device, then server, then policy helper. An explicit false or empty block replaces a lower-priority true; this is a preference, not a security floor." + } + }, + "model": { + "description": "Default model identifier for new conversations, including 'auto' or a concrete model ID. Users may override this startup default. Hosts should match it against the account's available models.", + "type": "string", + "x-composition": { + "strategy": "highest-layer-wins" + } + }, + "effortLevel": { + "description": "Reasoning effort for the managed concrete model. A bare string locks the value; use {\"overridable\":\"\"} to provide a default users and repositories may override. Invalid values are ignored without rejecting the rest of the managed document.", + "x-mdm-delivery": false, + "oneOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "object", + "properties": { + "overridable": { + "type": "string", + "minLength": 1 + } }, - { - "description": "Locks only the named customization surfaces.", - "type": "array", - "items": { + "required": [ + "overridable" + ], + "additionalProperties": false + } + ], + "x-composition": { + "allowOverridable": true, + "strategy": "enum-order-wins", + "collapseStrategy": "enum-order-permissive-wins", + "description": "Shared settings-engine annotations, not an independent-options execution contract. This runtime keeps model, effortLevel, and contextTier coupled: options come only from the channel supplying the winning concrete model. Use managedSettings.compose for the runtime's effective result." + } + }, + "contextTier": { + "description": "Context tier for the managed concrete model. A bare string locks the value; use {\"overridable\":\"\"} to provide a default users and repositories may override. Invalid values are ignored without rejecting the rest of the managed document.", + "x-mdm-delivery": false, + "oneOf": [ + { + "type": "string", + "enum": [ + "default", + "long_context" + ] + }, + { + "type": "object", + "properties": { + "overridable": { "type": "string", "enum": [ - "skills", - "agents", - "hooks", - "mcp" + "default", + "long_context" ] - }, - "uniqueItems": true - } - ], - "x-composition": { - "strategy": "most-restrictive-wins", - "description": "Union of locked surfaces across managed sources: a surface locked by any higher-priority managed source stays locked; the boolean true form is equivalent to locking every surface." + } + }, + "required": [ + "overridable" + ], + "additionalProperties": false } - }, - "allowManagedMcpServersOnly": { - "description": "Stricter overlay on top of `strictPluginOnlyCustomization`: when true, only the managed `allowedMcpServers` allowlist (plus MCP servers provided by managed/force-enabled plugins and any fixed managed-MCP deployment) governs which MCP servers are eligible. Lower-precedence user and workspace/project allowlist entries cannot broaden it; `deniedMcpServers` retains its restrictive deny-wins merge. Trusted first-party servers (e.g. the built-in GitHub MCP server) remain exempt. Unset or false preserves existing behavior.", - "type": "boolean", - "x-composition": { - "strategy": "most-restrictive-wins", - "description": "Sticky-true: a lower-priority source cannot undo allowManagedMcpServersOnly:true set by a higher-priority managed source." + ], + "x-composition": { + "allowOverridable": true, + "strategy": "enum-order-wins", + "collapseStrategy": "enum-order-permissive-wins", + "description": "Shared settings-engine annotations, not an independent-options execution contract. This runtime keeps model, effortLevel, and contextTier coupled: options come only from the channel supplying the winning concrete model. Use managedSettings.compose for the runtime's effective result." + } + }, + "autoTier": { + "description": "Default Auto routing preference for new conversations when the selected model is auto. A bare string strictly locks the tier and overrides user and repository settings. Use {\"overridable\":\"\"} to provide an organization default that users and repositories may override.", + "oneOf": [ + { + "type": "string", + "minLength": 1, + "pattern": "^[^\\s\\u0000-\\u001F\\u007F-\\u009F]+$" + }, + { + "type": "object", + "properties": { + "overridable": { + "type": "string", + "minLength": 1, + "pattern": "^[^\\s\\u0000-\\u001F\\u007F-\\u009F]+$" + } + }, + "required": [ + "overridable" + ], + "additionalProperties": false } - }, - "allowManagedHooksOnly": { - "description": "Stricter overlay on top of `strictPluginOnlyCustomization`: when true, only enterprise-managed hooks and hooks supplied by plugins force-enabled through managed `enabledPlugins` may load. User, workspace/project, and otherwise user-enabled plugin hooks are blocked. This does not silently grant additional tool or network access; normal managed permission and sandbox policies still apply. Unset or false preserves existing behavior.", - "type": "boolean", - "x-composition": { - "strategy": "most-restrictive-wins", - "description": "Sticky-true: a lower-priority source cannot undo allowManagedHooksOnly:true set by a higher-priority managed source." + ], + "x-composition": { + "allowOverridable": true, + "strategy": "enum-order-wins", + "collapseStrategy": "enum-order-permissive-wins", + "description": "Shared settings-engine annotations are descriptive, not a complete specification of this runtime's composition rules. Use managedSettings.compose for the runtime's effective result." + } + }, + "ensembleTier": { + "description": "Managed Ensemble tier, used with the virtual auto model. A bare tier locks the preference; an overridable wrapper supplies a default. Presence in any managed source suppresses autoTier across all sources. Explicit unmanaged means no managed tier and no legacy fallback. When no source supplies ensembleTier, responses expose legacy efficiency and balance as balance and intelligence as high; these compatibility aliases do not change the legacy runtime preference. Never synthesize autoTier from ensembleTier.", + "examples": [ + "balance", + "high", + "max", + "unmanaged", + { + "overridable": "high" } - }, - "features": { - "description": "Open map of managed product feature availability. A false value blocks the named feature; true or absent does not force-enable it.", - "type": "object", - "additionalProperties": { - "type": "boolean" + ], + "oneOf": [ + { + "type": "string", + "enum": [ + "balance", + "high", + "max", + "unmanaged" + ] + }, + { + "type": "object", + "properties": { + "overridable": { + "type": "string", + "enum": [ + "balance", + "high", + "max", + "unmanaged" + ] + } + }, + "required": [ + "overridable" + ], + "additionalProperties": false } - }, - "vscode": { - "description": "VS Code-specific managed preferences. Preserved by the shared runtime for VS Code to consume; the runtime and other clients do not interpret these preferences. Delivered through server-managed or file-based settings, not native MDM.", - "type": "object", - "additionalProperties": false, - "x-mdm-delivery": false, - "properties": { - "temporaryDefaultToLocalHarness": { - "description": "Temporary escape hatch: true defaults new VS Code chats to the Local harness without disabling telemetry or preventing users from choosing the Copilot harness. False or absent restores normal rollout behavior. This preference must not override governance requiring the Copilot harness, including enforced sandboxing and runtime-owned permission policies.", - "type": "boolean" + ], + "x-composition": { + "allowOverridable": true, + "strategy": "enum-order-wins", + "collapseStrategy": "enum-order-permissive-wins", + "description": "Uses the existing Auto channel precedence among explicit Ensemble settings; legacy Auto settings do not compete." + } + }, + "permissions": { + "description": "Controls that govern permissions mode behavior.", + "type": "object", + "additionalProperties": false, + "properties": { + "model": { + "description": "Legacy location of the model default in file and server policy. Accepted as a fallback when top-level model is absent, then canonicalized to the top-level key. Not supported through native plist/registry delivery.", + "x-mdm-delivery": false, + "type": "string", + "deprecated": true + }, + "disableBypassPermissionsMode": { + "description": "Controls bypass-permissions ('allow-all' / 'yolo') escalation. 'disable' blocks combined bypass mode and individual tool, path, and URL allow-all flags across CLI and runtime APIs. Advisory auto-approval remains available because normal prompt paths stay active. Deliberately unconstrained: a client must not reject the document over a value it does not recognize, so a value a newer client or the server understands never fails the whole policy. Because the key is present only to restrict something, a client that cannot interpret the value must fail closed and enforce the most restrictive mode it knows.", + "type": "string", + "x-composition": { + "strategy": "most-restrictive-wins", + "restrictiveness": [ + "disable" + ], + "description": "The composed value is the most restrictive one any managed source supplies, ranked by 'restrictiveness' (most restrictive first). An absent key imposes no restriction. Any value outside that list ranks above every listed value and is enforced as the first entry, so an unrecognized string can never relax a restriction another source contributed." } }, - "x-composition": { - "strategy": "highest-layer-wins", - "description": "The whole vscode block is selected from the highest-priority channel that supplies it: device, then server, then policy helper. An explicit false or empty block replaces a lower-priority true; this is a preference, not a security floor." - } - }, - "model": { - "description": "Default model identifier for new conversations, including 'auto' or a concrete model ID. Users may override this startup default. Hosts should match it against the account's available models.", - "type": "string", - "x-composition": { - "strategy": "highest-layer-wins" + "disableAssistedPermissionsMode": { + "description": "When true, prevents Assisted Permissions from being activated; an active or requested Assisted mode falls back to Manual Approval. Assisted Permissions uses probabilistic LLM review to reduce prompts and is not a security boundary. Managed permission rules, sandboxing, least-privilege credentials, and egress controls remain the hard boundaries. Unset or false imposes no restriction.", + "type": "boolean", + "x-composition": { + "strategy": "most-restrictive-wins", + "description": "Sticky-true: a lower-priority managed source cannot undo disableAssistedPermissionsMode:true set by another source." + } + }, + "defaultMode": { + "description": "Starting permission mode for new sessions: 'manual' (Manual Approval), 'assisted' (Assisted Permissions), or 'allow-all'. It replaces the user's defaultPermissionMode setting and organization targeting, but an explicit choice at startup (a command-line flag or a host request) still wins, and users can change the mode during the session. Resumed sessions keep their mode. A default blocked by disableAssistedPermissionsMode or disableBypassPermissionsMode, or Assisted when it is unavailable, starts in Manual Approval. Unrecognized values are treated as 'manual'.", + "type": "string", + "x-composition": { + "strategy": "most-restrictive-wins", + "restrictiveness": [ + "manual", + "assisted", + "allow-all" + ], + "description": "The composed value is the most restrictive one any managed source supplies, ranked by 'restrictiveness' (most restrictive first)." + } + }, + "deny": { + "description": "Permission rules that block matching operations. Deny takes precedence over ask and allow and is unioned across managed sources.", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "x-composition": { + "strategy": "deny-wins" + } + }, + "ask": { + "description": "Permission rules that require human approval. Ask takes precedence over allow and is unioned across managed sources.", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "x-composition": { + "strategy": "deny-wins" + } + }, + "allow": { + "description": "Permission rules that allow matching operations without prompting. Every managed source that declares an allow list must admit the operation.", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "x-composition": { + "strategy": "allowlist-intersection" + } + }, + "limitTo": { + "description": "Closed-world boundary for the rule families it names. Only Domain(...) rules are supported: any agent network request to a domain outside the boundary is denied without prompting, and user rules, saved approvals, and allow-all modes cannot widen it. It does not restrict shell, read, or write operations by themselves, but detected URLs in shell requests are Domain requests and must satisfy the boundary. An empty list denies every Domain request. An invalid list or any invalid entry is reported as a configuration error and enforced as an empty list, without discarding other valid permission rules; effective settings report the enforced empty list. Deny takes precedence over limitTo, and ask and allow only refine requests inside the boundary. Every managed source that declares a limitTo list must admit the request. This permission boundary does not enable the sandbox or modify sandbox.userPolicy.network; sandbox network restrictions must be configured independently.", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "x-composition": { + "strategy": "allowlist-intersection" + } } - }, - "effortLevel": { - "description": "Reasoning effort for the managed concrete model. A bare string locks the value; use {\"overridable\":\"\"} to provide a default users and repositories may override. Invalid values are ignored without rejecting the rest of the managed document.", - "x-mdm-delivery": false, - "oneOf": [ - { + } + }, + "remoteControl": { + "description": "Controls whether Copilot CLI sessions running on this device can be controlled from other devices. 'enabled' permits control, 'disabled' blocks control, and 'requireSSO' permits control only when the client is SSO-authorized for at least one configured GitHub.com organization or authenticated to at least one configured GitHub Enterprise Cloud domain.", + "type": "object", + "additionalProperties": false, + "required": [ + "mode" + ], + "properties": { + "mode": { + "description": "Policy for controlling sessions on this device from other devices.", + "type": "string", + "enum": [ + "enabled", + "disabled", + "requireSSO" + ], + "x-composition": { + "strategy": "enum-order-wins" + } + }, + "githubDotComOrganizations": { + "description": "GitHub.com organization logins. When mode is 'requireSSO', the client must be SSO-authorized for at least one organization in this list.", + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 }, - { - "type": "object", + "uniqueItems": true + }, + "githubEnterpriseCloudDomains": { + "description": "GitHub Enterprise Cloud with data residency domains. Accepted formats are the tenant's URL (for example, 'https://octocorp.ghe.com'), bare host ('octocorp.ghe.com'), or tenant slug ('octocorp'). When mode is 'requireSSO', authentication to any domain in this list satisfies the policy.", + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + } + }, + "allOf": [ + { + "if": { "properties": { - "overridable": { - "type": "string", - "minLength": 1 + "mode": { + "const": "requireSSO" } }, "required": [ - "overridable" - ], - "additionalProperties": false + "mode" + ] + }, + "then": { + "anyOf": [ + { + "required": [ + "githubDotComOrganizations" + ] + }, + { + "required": [ + "githubEnterpriseCloudDomains" + ] + } + ] } - ], - "x-composition": { - "allowOverridable": true, - "strategy": "enum-order-wins", - "collapseStrategy": "enum-order-permissive-wins", - "description": "Shared settings-engine annotations, not an independent-options execution contract. This runtime keeps model, effortLevel, and contextTier coupled: options come only from the channel supplying the winning concrete model. Use managedSettings.compose for the runtime's effective result." } + ], + "x-composition": { + "strategy": "enum-order-wins" + } + }, + "enabledPlugins": { + "description": "Declarative plugin allow/deny list. Keys use the format PLUGIN-NAME@MARKETPLACE-NAME. true = must be enabled, false = must be disabled.", + "type": "object", + "additionalProperties": { + "type": "boolean" }, - "contextTier": { - "description": "Context tier for the managed concrete model. A bare string locks the value; use {\"overridable\":\"\"} to provide a default users and repositories may override. Invalid values are ignored without rejecting the rest of the managed document.", - "x-mdm-delivery": false, - "oneOf": [ - { - "type": "string", - "enum": [ - "default", - "long_context" - ] + "x-composition": { + "strategy": "deny-wins" + } + }, + "extraKnownMarketplaces": { + "description": "Managed additional marketplace sources beyond built-ins. Each entry defines how to resolve a marketplace name to a source.", + "type": "object", + "additionalProperties": { + "type": "object", + "required": [ + "source" + ], + "properties": { + "autoUpdate": { + "description": "When true, this marketplace's installed plugins are refreshed to the latest catalog version at the start of a Copilot CLI session (interactive or `-p`); SDK/server sessions do not auto-update. Honored from the user's own settings or from managed (MDM/server) settings — a repository-level opt-in is ignored. On a same-name collision the precedence is: a built-in first-party marketplace wins, then managed (which replaces the whole same-named user entry, so a managed entry without \"autoUpdate\": true removes the user's opt-in), then user.", + "type": "boolean" }, - { + "source": { + "description": "Source descriptor for this marketplace.", "type": "object", + "required": [ + "source" + ], "properties": { - "overridable": { + "source": { + "description": "Source type for the marketplace.", "type": "string", "enum": [ - "default", - "long_context" + "github", + "git", + "directory" ] } }, - "required": [ - "overridable" - ], - "additionalProperties": false + "oneOf": [ + { + "properties": { + "source": { + "const": "github" + }, + "repo": { + "description": "Repository NWO (owner/repo) containing the marketplace plugins.", + "type": "string", + "pattern": "^[^/:]+/[^/:]+$" + }, + "ref": { + "description": "Optional branch, tag, or SHA to pin the marketplace source to.", + "type": "string" + }, + "path": { + "description": "Optional subdirectory within the repository containing plugins.", + "type": "string" + } + }, + "required": [ + "source", + "repo" + ], + "additionalProperties": false + }, + { + "properties": { + "source": { + "const": "git" + }, + "url": { + "description": "Git URL for the marketplace repository.", + "type": "string", + "format": "uri" + }, + "ref": { + "description": "Optional branch, tag, or SHA to pin the marketplace source to.", + "type": "string" + }, + "path": { + "description": "Optional subdirectory within the repository containing plugins.", + "type": "string" + } + }, + "required": [ + "source", + "url" + ], + "additionalProperties": false + }, + { + "properties": { + "source": { + "const": "directory" + }, + "path": { + "description": "Directory containing the marketplace source.", + "type": "string" + } + }, + "required": [ + "source", + "path" + ], + "additionalProperties": false + } + ] } - ], - "x-composition": { - "allowOverridable": true, - "strategy": "enum-order-wins", - "collapseStrategy": "enum-order-permissive-wins", - "description": "Shared settings-engine annotations, not an independent-options execution contract. This runtime keeps model, effortLevel, and contextTier coupled: options come only from the channel supplying the winning concrete model. Use managedSettings.compose for the runtime's effective result." - } + }, + "additionalProperties": false }, - "autoTier": { - "description": "Default Auto routing preference for new conversations when the selected model is auto. A bare string strictly locks the tier and overrides user and repository settings. Use {\"overridable\":\"\"} to provide an organization default that users and repositories may override.", + "x-composition": { + "strategy": "allowlist-intersection" + } + }, + "strictKnownMarketplaces": { + "description": "Strict marketplace allowlist. Each entry must match a supported source descriptor (github, git, url, npm, file, directory, hostPattern, or pathPattern). Empty array means complete lockdown.", + "type": "array", + "items": { "oneOf": [ - { - "type": "string", - "minLength": 1, - "pattern": "^[^\\s\\u0000-\\u001F\\u007F-\\u009F]+$" - }, { "type": "object", + "required": [ + "source", + "repo" + ], "properties": { - "overridable": { + "source": { + "const": "github" + }, + "repo": { + "description": "Repository NWO (owner/repo) containing the marketplace plugins.", "type": "string", - "minLength": 1, - "pattern": "^[^\\s\\u0000-\\u001F\\u007F-\\u009F]+$" + "pattern": "^[^/:]+/[^/:]+$" + }, + "ref": { + "description": "Optional branch, tag, or SHA to pin the marketplace source to.", + "type": "string" + }, + "path": { + "description": "Optional subdirectory within the repository containing plugins.", + "type": "string" } }, - "required": [ - "overridable" - ], "additionalProperties": false - } - ], - "x-composition": { - "allowOverridable": true, - "strategy": "enum-order-wins", - "collapseStrategy": "enum-order-permissive-wins", - "description": "Shared settings-engine annotations are descriptive, not a complete specification of this runtime's composition rules. Use managedSettings.compose for the runtime's effective result." - } - }, - "ensembleTier": { - "description": "Managed Ensemble tier, used with the virtual auto model. A bare tier locks the preference; an overridable wrapper supplies a default. Presence in any managed source suppresses autoTier across all sources. Explicit unmanaged means no managed tier and no legacy fallback. When no source supplies ensembleTier, responses expose legacy efficiency and balance as balance and intelligence as high; these compatibility aliases do not change the legacy runtime preference. Never synthesize autoTier from ensembleTier.", - "examples": [ - "balance", - "high", - "max", - "unmanaged", - { - "overridable": "high" - } - ], - "oneOf": [ - { - "type": "string", - "enum": [ - "balance", - "high", - "max", - "unmanaged" - ] }, { "type": "object", + "required": [ + "source", + "url" + ], "properties": { - "overridable": { + "source": { + "const": "git" + }, + "url": { + "description": "Git URL for the marketplace repository.", "type": "string", - "enum": [ - "balance", - "high", - "max", - "unmanaged" - ] + "format": "uri" + }, + "ref": { + "description": "Optional branch, tag, or SHA to pin the marketplace source to.", + "type": "string" + }, + "path": { + "description": "Optional subdirectory within the repository containing plugins.", + "type": "string" } }, + "additionalProperties": false + }, + { + "type": "object", "required": [ - "overridable" + "source", + "url" ], + "properties": { + "source": { + "const": "url" + }, + "url": { + "description": "HTTP(S) URL for the marketplace catalog.", + "type": "string", + "format": "uri" + }, + "headers": { + "description": "Optional HTTP headers for fetching the catalog.", + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, "additionalProperties": false - } - ], - "x-composition": { - "allowOverridable": true, - "strategy": "enum-order-wins", - "collapseStrategy": "enum-order-permissive-wins", - "description": "Uses the existing Auto channel precedence among explicit Ensemble settings; legacy Auto settings do not compete." - } - }, - "permissions": { - "description": "Controls that govern permissions mode behavior.", - "type": "object", - "additionalProperties": false, - "properties": { - "model": { - "description": "Legacy location of the model default in file and server policy. Accepted as a fallback when top-level model is absent, then canonicalized to the top-level key. Not supported through native plist/registry delivery.", - "x-mdm-delivery": false, - "type": "string", - "deprecated": true - }, - "disableBypassPermissionsMode": { - "description": "Controls bypass-permissions ('allow-all' / 'yolo') escalation. 'disable' blocks combined bypass mode and individual tool, path, and URL allow-all flags across CLI and runtime APIs. Advisory auto-approval remains available because normal prompt paths stay active. Deliberately unconstrained: a client must not reject the document over a value it does not recognize, so a value a newer client or the server understands never fails the whole policy. Because the key is present only to restrict something, a client that cannot interpret the value must fail closed and enforce the most restrictive mode it knows.", - "type": "string", - "x-composition": { - "strategy": "most-restrictive-wins", - "restrictiveness": [ - "disable" - ], - "description": "The composed value is the most restrictive one any managed source supplies, ranked by 'restrictiveness' (most restrictive first). An absent key imposes no restriction. Any value outside that list ranks above every listed value and is enforced as the first entry, so an unrecognized string can never relax a restriction another source contributed." - } }, - "disableAssistedPermissionsMode": { - "description": "When true, prevents Assisted Permissions from being activated; an active or requested Assisted mode falls back to Manual Approval. Assisted Permissions uses probabilistic LLM review to reduce prompts and is not a security boundary. Managed permission rules, sandboxing, least-privilege credentials, and egress controls remain the hard boundaries. Unset or false imposes no restriction.", - "type": "boolean", - "x-composition": { - "strategy": "most-restrictive-wins", - "description": "Sticky-true: a lower-priority managed source cannot undo disableAssistedPermissionsMode:true set by another source." - } - }, - "defaultMode": { - "description": "Starting permission mode for new sessions: 'manual' (Manual Approval), 'assisted' (Assisted Permissions), or 'allow-all'. It replaces the user's defaultPermissionMode setting and organization targeting, but an explicit choice at startup (a command-line flag or a host request) still wins, and users can change the mode during the session. Resumed sessions keep their mode. A default blocked by disableAssistedPermissionsMode or disableBypassPermissionsMode, or Assisted when it is unavailable, starts in Manual Approval. Unrecognized values are treated as 'manual'.", - "type": "string", - "x-composition": { - "strategy": "most-restrictive-wins", - "restrictiveness": [ - "manual", - "assisted", - "allow-all" - ], - "description": "The composed value is the most restrictive one any managed source supplies, ranked by 'restrictiveness' (most restrictive first)." - } - }, - "deny": { - "description": "Permission rules that block matching operations. Deny takes precedence over ask and allow and is unioned across managed sources.", - "type": "array", - "items": { - "type": "string", - "minLength": 1 + { + "type": "object", + "required": [ + "source", + "package" + ], + "properties": { + "source": { + "const": "npm" + }, + "package": { + "description": "NPM package name for the marketplace catalog.", + "type": "string" + } }, - "x-composition": { - "strategy": "deny-wins" - } + "additionalProperties": false }, - "ask": { - "description": "Permission rules that require human approval. Ask takes precedence over allow and is unioned across managed sources.", - "type": "array", - "items": { - "type": "string", - "minLength": 1 + { + "type": "object", + "required": [ + "source", + "path" + ], + "properties": { + "source": { + "const": "file" + }, + "path": { + "description": "Filesystem path to the marketplace catalog.", + "type": "string" + } }, - "x-composition": { - "strategy": "deny-wins" - } + "additionalProperties": false }, - "allow": { - "description": "Permission rules that allow matching operations without prompting. Every managed source that declares an allow list must admit the operation.", - "type": "array", - "items": { - "type": "string", - "minLength": 1 + { + "type": "object", + "required": [ + "source", + "path" + ], + "properties": { + "source": { + "const": "directory" + }, + "path": { + "description": "Directory containing the marketplace catalog.", + "type": "string" + } }, - "x-composition": { - "strategy": "allowlist-intersection" - } + "additionalProperties": false }, - "limitTo": { - "description": "Closed-world boundary for the rule families it names. Only Domain(...) rules are supported: any agent network request to a domain outside the boundary is denied without prompting, and user rules, saved approvals, and allow-all modes cannot widen it. It does not restrict shell, read, or write operations by themselves, but detected URLs in shell requests are Domain requests and must satisfy the boundary. An empty list denies every Domain request. An invalid list or any invalid entry is reported as a configuration error and enforced as an empty list, without discarding other valid permission rules; effective settings report the enforced empty list. Deny takes precedence over limitTo, and ask and allow only refine requests inside the boundary. Every managed source that declares a limitTo list must admit the request. This permission boundary does not enable the sandbox or modify sandbox.userPolicy.network; sandbox network restrictions must be configured independently.", - "type": "array", - "items": { - "type": "string", - "minLength": 1 - }, - "x-composition": { - "strategy": "allowlist-intersection" - } - } - } - }, - "remoteControl": { - "description": "Controls whether Copilot CLI sessions running on this device can be controlled from other devices. 'enabled' permits control, 'disabled' blocks control, and 'requireSSO' permits control only when the client is SSO-authorized for at least one configured GitHub.com organization or authenticated to at least one configured GitHub Enterprise Cloud domain.", - "type": "object", - "additionalProperties": false, - "required": [ - "mode" - ], - "properties": { - "mode": { - "description": "Policy for controlling sessions on this device from other devices.", - "type": "string", - "enum": [ - "enabled", - "disabled", - "requireSSO" + { + "type": "object", + "required": [ + "source", + "hostPattern" ], - "x-composition": { - "strategy": "enum-order-wins" - } - }, - "githubDotComOrganizations": { - "description": "GitHub.com organization logins. When mode is 'requireSSO', the client must be SSO-authorized for at least one organization in this list.", - "type": "array", - "minItems": 1, - "items": { - "type": "string", - "minLength": 1 + "properties": { + "source": { + "const": "hostPattern" + }, + "hostPattern": { + "description": "Regex used to match marketplace hosts.", + "type": "string" + } }, - "uniqueItems": true + "additionalProperties": false }, - "githubEnterpriseCloudDomains": { - "description": "GitHub Enterprise Cloud with data residency domains. Accepted formats are the tenant's URL (for example, 'https://octocorp.ghe.com'), bare host ('octocorp.ghe.com'), or tenant slug ('octocorp'). When mode is 'requireSSO', authentication to any domain in this list satisfies the policy.", - "type": "array", - "minItems": 1, - "items": { - "type": "string", - "minLength": 1 - }, - "uniqueItems": true - } - }, - "allOf": [ { - "if": { - "properties": { - "mode": { - "const": "requireSSO" - } + "type": "object", + "required": [ + "source", + "pathPattern" + ], + "properties": { + "source": { + "const": "pathPattern" }, - "required": [ - "mode" - ] + "pathPattern": { + "description": "Regex used to match marketplace paths.", + "type": "string" + } + }, + "additionalProperties": false + } + ] + }, + "x-composition": { + "strategy": "allowlist-intersection" + } + }, + "forceRemoteSettingsRefresh": { + "description": "When true, requires a fresh server-managed settings fetch on startup even when a fresh persistent cache entry exists: the cached policy is never served, and — unlike the default path — it is not used as a fetch-failure fallback either. A failed startup fetch therefore fails closed on the unconfirmed policy (skipping both the 1h fast path and the 24h stale fallback) rather than reverting to a possibly-stale cached policy. The directive applies even when it lives in the cache — a cached server response carrying it forces the next startup to re-fetch. The device (MDM) value takes precedence over a cached server value, so an explicit MDM false overrides a server-side true. Settable via the server-managed policy or the device (MDM) layer. The policy helper cannot set this key. Note: like every typed MDM value, when delivered natively (plist/registry) the boolean must be supplied as a JSON-encoded string (the string \"true\" or \"false\"); a native plist boolean (CFBoolean) or registry DWORD is not accepted. File-based (managed-settings.json) and server-managed documents use a real JSON boolean.", + "type": "boolean" + }, + "policyHelper": { + "description": "Registers an executable that supplies the lowest-priority managed-settings layer. If device and server policy register different executables, the device registration takes priority.", + "type": "object", + "additionalProperties": false, + "required": [ + "path" + ], + "properties": { + "path": { + "description": "Executable registration in one of three forms: an absolute path naming a file, a home-relative path (`~/...` or `~\\...`) with a path after the separator, or a bare program name with no path separator. The registration form is the trust decision: an absolute path pins the exact binary, while a bare name defers to the binary resolved from the user's PATH. The value is used verbatim, so other relative paths, root-only paths, a trailing separator, surrounding whitespace, and any other `~` form (`~`, `~/`, `~bob`) are rejected.", + "type": "string", + "minLength": 1, + "allOf": [ + { + "$comment": "No surrounding whitespace, no trailing separator, and no bare dot component.", + "not": { + "pattern": "^\\s|\\s$|[/\\\\]$|^\\.{1,2}$" + } }, - "then": { + { + "$comment": "Absolute (POSIX, Windows drive, or Windows UNC), home-relative, or a bare name carrying no separator and not claiming the reserved `~` prefix.", "anyOf": [ { - "required": [ - "githubDotComOrganizations" - ] + "pattern": "^/.+" }, { - "required": [ - "githubEnterpriseCloudDomains" - ] + "pattern": "^[A-Za-z]:[/\\\\].+" + }, + { + "pattern": "^\\\\\\\\[^/\\\\]+[/\\\\].+" + }, + { + "pattern": "^~[/\\\\].+" + }, + { + "pattern": "^[^~/\\\\][^/\\\\]*$" } ] } + ] + }, + "args": { + "description": "Arguments passed to the helper executable, in order. At the spawn boundary, the runtime passes each element to the program verbatim as a separate argument without asking a shell to parse another command. Use this to select a subcommand of a helper that serves several callers.", + "type": "array", + "items": { + "type": "string" } - ], - "x-composition": { - "strategy": "enum-order-wins" + }, + "timeoutMs": { + "description": "Milliseconds the runtime waits for one helper run before it gives up. The helper runs while the session starts, so the value is capped to keep startup bounded.", + "type": "integer", + "minimum": 1, + "maximum": 30000, + "default": 5000 } + } + }, + "forceLoginOrgs": { + "description": "Pins sign-in to an approved set of GitHub organizations. Each entry is a GitHub organization login (the URL slug, for example 'octocorp'); matching is case-insensitive. When set, Copilot only runs for an account that belongs to at least one listed organization — a personal account, or an account belonging only to some other enterprise, is refused with an actionable error instead of silently escaping enterprise-managed policy. Because it must be able to redirect a developer's *first* sign-in, deploy it via the device channel (MDM plist/registry or managed-settings.json); the server-managed channel only reaches accounts that already authenticated into the organization. The policy helper cannot set this key. The setting also blocks bring-your-own-key (BYOK / API-key) authentication outright, since such credentials carry no GitHub identity whose organization membership could be verified. An empty array means no pin, so the control can be turned off without deleting the key. Fails closed: if the value is present but unusable, or the managed policy that carries it cannot be read from a device that is known to be managed, no account is permitted to sign in until the value is fixed. Note: like every typed MDM value, when delivered natively (plist/registry) the array must be supplied as a JSON-encoded string (for example \"[\\\"octocorp\\\"]\"). File-based (managed-settings.json) and server-managed documents use a real JSON array.", + "type": "array", + "items": { + "type": "string", + "description": "A GitHub organization login: 1-39 characters of ASCII letters and digits, optionally separated by single hyphens. Surrounding whitespace is trimmed. URLs, 'org/team' slugs, and email addresses are rejected.", + "pattern": "^\\s*[A-Za-z0-9](?:-?[A-Za-z0-9]){0,38}\\s*$" }, - "enabledPlugins": { - "description": "Declarative plugin allow/deny list. Keys use the format PLUGIN-NAME@MARKETPLACE-NAME. true = must be enabled, false = must be disabled.", - "type": "object", - "additionalProperties": { + "uniqueItems": true + }, + "policyHelperFailureMode": { + "description": "What happens when the registered policy helper does not produce a usable layer, because it failed to start, timed out, exited non-zero, exceeded the output limit, or emitted output that failed schema validation. Use \"ignore\" (the default) to resolve policy from the remaining channels and report the fault as an error. Use \"failClosed\" to treat the fault as making policy undetermined, which engages the sandbox fail-closed protection. If device and server policy set different values, the device value takes priority.", + "type": "string", + "enum": [ + "ignore", + "failClosed" + ], + "default": "ignore" + }, + "telemetry": { + "description": "Enterprise-mandated OpenTelemetry (OTel) configuration. Admin-only and non-overridable by users; consumed uniformly by Copilot clients. Controls where Copilot telemetry is exported and whether sensitive content is captured. Operates independently from GitHub's own audit streaming.", + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "description": "Enables OTel emission. When true, users cannot disable telemetry export via local settings or environment variables.", "type": "boolean" }, - "x-composition": { - "strategy": "deny-wins" - } - }, - "extraKnownMarketplaces": { - "description": "Managed additional marketplace sources beyond built-ins. Each entry defines how to resolve a marketplace name to a source.", - "type": "object", - "additionalProperties": { + "endpoint": { + "description": "OTLP collector endpoint. Equivalent to the OTEL_EXPORTER_OTLP_ENDPOINT environment variable; the managed value takes precedence over any user-provided value.", + "type": "string", + "format": "uri" + }, + "filePath": { + "description": "Selects a file destination instead of the collector endpoint and automatically enables OTel unless this managed telemetry block sets enabled to false. Empty or non-string canonical policy values are validation errors. For Copilot runtime and VS Code Agent Host consumers, a relative path is resolved under COPILOT_HOME when that override is nonempty, otherwise under the exporting execution host's default /.copilot directory; a COPILOT_HOME override must be absolute. A leading ~/ is expanded using the exporting execution host's home directory. This path-resolution contract does not claim behavior for server-side or other consumers. Parent directories are created as needed. Path-resolution and file I/O failures are logged and skipped without blocking Copilot or falling back to a network destination.", + "type": "string", + "minLength": 1 + }, + "protocol": { + "description": "OTLP transport protocol used for all exported telemetry. Equivalent to the OTEL_EXPORTER_OTLP_PROTOCOL environment variable; the managed value takes precedence over any user-provided value. Recognized values are \"http/json\" and \"http/protobuf\" (\"grpc\" is accepted for forward compatibility but is not implemented in this version and falls back to the default protocol).", + "type": "string" + }, + "headers": { + "description": "Auth and routing headers sent to the OTLP collector. Equivalent to OTEL_EXPORTER_OTLP_HEADERS. Values are literal strings (no secret interpolation in this version) and are never surfaced in logs or status output. Requires endpoint in the same telemetry block so managed headers are never sent to a destination resolved from user-controlled environment variables. The endpoint must use HTTP or HTTPS. HTTP transmits every header value in plaintext and produces a runtime warning; authentication credentials, including bearer tokens, are confidential and must use HTTPS. Use HTTP only for non-confidential routing metadata on a trusted network.", "type": "object", - "required": [ - "source" - ], - "properties": { - "autoUpdate": { - "description": "When true, this marketplace's installed plugins are refreshed to the latest catalog version at the start of a Copilot CLI session (interactive or `-p`); SDK/server sessions do not auto-update. Honored from the user's own settings or from managed (MDM/server) settings — a repository-level opt-in is ignored. On a same-name collision the precedence is: a built-in first-party marketplace wins, then managed (which replaces the whole same-named user entry, so a managed entry without \"autoUpdate\": true removes the user's opt-in), then user.", - "type": "boolean" + "additionalProperties": { + "type": "string" + } + }, + "headersProvider": { + "description": "Either false to disable dynamic telemetry headers, or a shell-free command that produces fresh headers. The executable path follows policy-helper path rules; arguments are passed verbatim without shell evaluation.", + "oneOf": [ + { + "const": false }, - "source": { - "description": "Source descriptor for this marketplace.", + { "type": "object", + "additionalProperties": false, "required": [ - "source" + "path" ], "properties": { - "source": { - "description": "Source type for the marketplace.", + "path": { "type": "string", - "enum": [ - "github", - "git", - "directory" - ] - } - }, - "oneOf": [ - { - "properties": { - "source": { - "const": "github" - }, - "repo": { - "description": "Repository NWO (owner/repo) containing the marketplace plugins.", - "type": "string", - "pattern": "^[^/:]+/[^/:]+$" - }, - "ref": { - "description": "Optional branch, tag, or SHA to pin the marketplace source to.", - "type": "string" + "minLength": 1, + "allOf": [ + { + "not": { + "pattern": "^\\s|\\s$|[/\\\\]$|^\\.{1,2}$" + } }, - "path": { - "description": "Optional subdirectory within the repository containing plugins.", - "type": "string" + { + "anyOf": [ + { + "pattern": "^/.+" + }, + { + "pattern": "^[A-Za-z]:[/\\\\].+" + }, + { + "pattern": "^\\\\\\\\[^/\\\\]+[/\\\\].+" + }, + { + "pattern": "^~[/\\\\].+" + }, + { + "pattern": "^[^~/\\\\][^/\\\\]*$" + } + ] } - }, - "required": [ - "source", - "repo" - ], - "additionalProperties": false + ] }, - { - "properties": { - "source": { - "const": "git" - }, - "url": { - "description": "Git URL for the marketplace repository.", - "type": "string", - "format": "uri" - }, - "ref": { - "description": "Optional branch, tag, or SHA to pin the marketplace source to.", - "type": "string" - }, - "path": { - "description": "Optional subdirectory within the repository containing plugins.", - "type": "string" - } + "args": { + "type": "array", + "items": { + "type": "string" }, - "required": [ - "source", - "url" - ], - "additionalProperties": false + "default": [] }, - { - "properties": { - "source": { - "const": "directory" - }, - "path": { - "description": "Directory containing the marketplace source.", - "type": "string" - } - }, - "required": [ - "source", - "path" - ], - "additionalProperties": false + "refreshIntervalMs": { + "type": "integer", + "minimum": 1, + "default": 1740000 } - ] + } } - }, - "additionalProperties": false + ] + }, + "resourceAttributes": { + "description": "Extra OTel resource attributes (e.g. cost center / team routing). Equivalent to OTEL_RESOURCE_ATTRIBUTES.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "capture": { + "description": "Fine-grained controls for sensitive OpenTelemetry attributes. Every field defaults to false. Explicit fields override the legacy captureContent shorthand for the same data class.", + "type": "object", + "additionalProperties": false, + "properties": { + "identity": { + "description": "Emits identity attributes for enterprise audit attribution: the authenticated provider account as user.name, the local operating-system account as process.user.name, and the local machine as host.name. Does not affect enduser.pseudo.id.", + "type": "boolean", + "default": false + }, + "prompts": { + "description": "Captures user, system, and developer prompt content and associated input context, including supported attachments.", + "type": "boolean", + "default": false + }, + "responses": { + "description": "Captures assistant response and supported reasoning content.", + "type": "boolean", + "default": false + }, + "toolArguments": { + "description": "Captures raw tool arguments, tool descriptions and schemas, and sensitive tool or MCP identifiers.", + "type": "boolean", + "default": false + }, + "toolOutput": { + "description": "Captures tool result content and execution error detail.", + "type": "boolean", + "default": false + }, + "policyDetail": { + "description": "Captures raw policy details such as sandbox filesystem paths. Bounded policy posture, counts, outcomes, and classifications remain available when false.", + "type": "boolean", + "default": false + } + } + }, + "captureContent": { + "description": "Legacy shorthand for prompts, responses, toolArguments, toolOutput, and policyDetail. Does not enable identity. Explicit capture fields override this value for their class. Defaults to false.", + "type": "boolean" + }, + "lockCaptureContent": { + "description": "When true, prevents local settings from enabling a legacy content class unless that class or captureContent is explicitly set in this managed telemetry block. Does not govern identity.", + "type": "boolean" }, - "x-composition": { - "strategy": "allowlist-intersection" + "serviceName": { + "description": "Overrides the OTel service.name resource attribute. Equivalent to OTEL_SERVICE_NAME.", + "type": "string" } }, - "strictKnownMarketplaces": { - "description": "Strict marketplace allowlist. Each entry must match a supported source descriptor (github, git, url, npm, file, directory, hostPattern, or pathPattern). Empty array means complete lockdown.", - "type": "array", - "items": { - "oneOf": [ - { + "x-composition": { + "strategy": "highest-priority-managed-block-wins", + "description": "The highest-priority managed channel that defines telemetry supplies the complete managed telemetry block; weaker managed telemetry blocks do not merge into it. Explicit managed fields are authoritative over local or environment configuration. Omitted fields fall back according to the client resolver, including legacy captureContent and lockCaptureContent compatibility." + }, + "allOf": [ + { + "if": { + "properties": { + "headersProvider": { + "type": "object" + } + }, + "required": [ + "headersProvider" + ] + }, + "then": { + "required": [ + "endpoint" + ] + } + } + ] + }, + "sandbox": { + "description": "Enterprise-mandated sandbox floor. Admin-set values compose with the user's own sandbox configuration in the safe (more-restrictive) direction and cannot be loosened by the user. Only non-deprecated keys are part of the managed surface. The restrictiveness merge itself is applied by the client at resolve time (see #11374); recognizing the block here is behavior-neutral.", + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "description": "Force sandboxing on by default. When a managed source sets true, ordinary user configuration, startup flags, and sandbox settings cannot disable the sandbox. If the effective policy also allows bypass, the user may explicitly disable it for the current session from an active sandbox-bypass permission prompt.", + "type": "boolean", + "x-composition": { + "strategy": "force-on-wins" + } + }, + "failIfUnavailable": { + "description": "When true and the composed managed policy also sets sandbox.enabled to true, the managed sandbox becomes mandatory. Copilot must block model and tool execution if the policy cannot be validated, compiled, or enforced by a usable sandbox backend, and the user cannot disable it. This control does not enable sandboxing by itself; true from any managed source wins.", + "type": "boolean", + "x-composition": { + "strategy": "force-on-wins" + } + }, + "allowBypass": { + "description": "Whether tools may offer the user a per-operation sandbox bypass and whether the user may explicitly disable the sandbox for the current session from an active bypass permission prompt. Managed false wins: if policy forbids bypass, the user cannot re-enable either escape hatch.", + "type": "boolean", + "x-composition": { + "strategy": "deny-wins" + } + }, + "addCurrentWorkingDirectory": { + "description": "Whether the current working directory is auto-added to the sandbox readwrite paths. Managed false wins.", + "type": "boolean", + "x-composition": { + "strategy": "deny-wins" + } + }, + "sandboxMcpServers": { + "description": "Whether MCP servers are spawned inside the sandbox. Routing flag: true (servers sandboxed) is the more-restrictive value, so a managed true wins (force-on) and a user false cannot loosen it.", + "type": "boolean", + "x-composition": { + "strategy": "force-on-wins" + } + }, + "sandboxLspServers": { + "description": "Whether LSP servers are spawned inside the sandbox. Routing flag: true (servers sandboxed) is the more-restrictive value, so a managed true wins (force-on) and a user false cannot loosen it.", + "type": "boolean", + "x-composition": { + "strategy": "force-on-wins" + } + }, + "auth": { + "description": "Credential-injection capabilities applied while the sandbox is enabled.", + "type": "object", + "additionalProperties": false, + "properties": { + "git": { + "description": "Whether a git token is injected so git works inside the sandbox. Credential-injection capability; managed false wins: policy can force it off but never force it on.", + "type": "boolean", + "x-composition": { + "strategy": "deny-wins" + } + }, + "gh": { + "description": "Whether a gh token is injected so gh works inside the sandbox. Credential-injection capability; managed false wins: policy can force it off but never force it on.", + "type": "boolean", + "x-composition": { + "strategy": "deny-wins" + } + } + } + }, + "allowDevToolAccess": { + "description": "Whether the sandbox automatically grants access to developer-tool files needed by builds, including the tool directories discovered on PATH and in toolchain environment variables (GOROOT, CARGO_HOME, JAVA_HOME, and similar), the user-profile tool directories, plus caches, registries, toolchains, and registry/proxy configuration in default home locations. Delivering false disables all of them, so a developer-tool path must then be granted explicitly through sandbox.userPolicy.filesystem. This governs only the developer-tool grants: the working directory (sandbox.addCurrentWorkingDirectory), temporary storage, session log paths, and the platform's system locations — which on macOS include the /usr/local and /opt/homebrew prefixes — are granted by their own rules and are unaffected. Managed false wins: policy can disable these grants but never force them on", + "type": "boolean", + "x-composition": { + "strategy": "deny-wins" + } + }, + "learningMode": { + "description": "Windows only. The denial-capture mode sandboxed shell commands start in when the sandbox is enabled: \"deny\" records and denies blocked access (the default), and \"allow\" records and allows filesystem and process access (permissive learning mode) while the network policy still enforces. With \"allow\", a command that is still refused offers only the full sandbox bypass, and only when sandbox.allowBypass permits it. \"deny\" is the stricter value and wins when managed sources disagree. Deliver \"allow\" through native MDM (the Windows registry policy hive, for example through Intune); an \"allow\" in managed-settings.json is ignored. Ignored on hosts without denial capture", + "type": "string", + "enum": [ + "deny", + "allow" + ], + "x-composition": { + "strategy": "enum-order-wins", + "description": "deny wins when managed sources disagree." + } + }, + "userPolicy": { + "description": "Fine-grained sandbox policy (filesystem, network, seatbelt). Deprecated sub-keys (userPolicy.experimental) are excluded from the managed surface.", + "type": "object", + "additionalProperties": false, + "properties": { + "filesystem": { + "description": "Filesystem access policy. Grant lists shrink under policy (intersection); the deny list grows (union).", "type": "object", - "required": [ - "source", - "repo" - ], + "additionalProperties": false, "properties": { - "source": { - "const": "github" + "readwritePaths": { + "description": "Paths granted read-write access. Managed policy can only shrink the effective grant (intersection).", + "type": "array", + "items": { + "type": "string" + }, + "x-composition": { + "strategy": "allowlist-intersection" + } }, - "repo": { - "description": "Repository NWO (owner/repo) containing the marketplace plugins.", - "type": "string", - "pattern": "^[^/:]+/[^/:]+$" - }, - "ref": { - "description": "Optional branch, tag, or SHA to pin the marketplace source to.", - "type": "string" - }, - "path": { - "description": "Optional subdirectory within the repository containing plugins.", - "type": "string" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "source", - "url" - ], - "properties": { - "source": { - "const": "git" - }, - "url": { - "description": "Git URL for the marketplace repository.", - "type": "string", - "format": "uri" - }, - "ref": { - "description": "Optional branch, tag, or SHA to pin the marketplace source to.", - "type": "string" - }, - "path": { - "description": "Optional subdirectory within the repository containing plugins.", - "type": "string" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "source", - "url" - ], - "properties": { - "source": { - "const": "url" - }, - "url": { - "description": "HTTP(S) URL for the marketplace catalog.", - "type": "string", - "format": "uri" + "readonlyPaths": { + "description": "Paths granted read-only access. Managed policy can only shrink the effective grant (intersection).", + "type": "array", + "items": { + "type": "string" + }, + "x-composition": { + "strategy": "allowlist-intersection" + } }, - "headers": { - "description": "Optional HTTP headers for fetching the catalog.", - "type": "object", - "additionalProperties": { + "deniedPaths": { + "description": "Paths explicitly denied. Managed policy can only add denials (union).", + "type": "array", + "items": { "type": "string" + }, + "x-composition": { + "strategy": "denylist-union" } } - }, - "additionalProperties": false + } }, - { + "network": { + "description": "Network access policy. Managed false wins for each toggle. Non-empty host allowlists intersect; denylists combine and take precedence. A managed proxy URL overrides the user's URL while credentials remain user-managed.", "type": "object", - "required": [ - "source", - "package" - ], + "additionalProperties": false, "properties": { - "source": { - "const": "npm" + "allowOutbound": { + "description": "Whether outbound network access is permitted. Managed false wins.", + "type": "boolean", + "x-composition": { + "strategy": "deny-wins" + } }, - "package": { - "description": "NPM package name for the marketplace catalog.", - "type": "string" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "source", - "path" - ], - "properties": { - "source": { - "const": "file" + "allowLocalNetwork": { + "description": "Whether local-network access is permitted. Managed false wins.", + "type": "boolean", + "x-composition": { + "strategy": "deny-wins" + } }, - "path": { - "description": "Filesystem path to the marketplace catalog.", - "type": "string" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "source", - "path" - ], - "properties": { - "source": { - "const": "directory" + "allowedHosts": { + "description": "Host allowlist for the built-in local proxy. Non-empty lists intersect with other configured allowlists; absent or empty lists add no restriction. Supports exact hosts, IP addresses, and *.example.com for strict subdomains.", + "type": "array", + "items": { + "$ref": "#/$defs/hostPattern" + }, + "x-composition": { + "strategy": "allowlist-intersection" + } }, - "path": { - "description": "Directory containing the marketplace catalog.", - "type": "string" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "source", - "hostPattern" - ], - "properties": { - "source": { - "const": "hostPattern" + "blockedHosts": { + "description": "Host denylist for the built-in local proxy. Combined with other denylists; deny takes precedence over allow. A domain also denies all its subdomains. IP addresses match exactly; *.example.com matches strict subdomains, and * denies every host.", + "type": "array", + "items": { + "$ref": "#/$defs/hostPattern" + }, + "x-composition": { + "strategy": "denylist-union" + } }, - "hostPattern": { - "description": "Regex used to match marketplace hosts.", - "type": "string" + "proxy": { + "description": "Enterprise-managed sandbox proxy endpoint. Only the URL is managed; username and password remain user-managed and are not admitted by this schema.", + "type": "object", + "additionalProperties": false, + "required": [ + "url" + ], + "properties": { + "url": { + "description": "Credential-free HTTP(S) proxy endpoint enforced over the user's configured proxy URL. The URL must contain only printable ASCII characters (percent-encode non-ASCII path data); the host must be an ASCII DNS hostname, dotted-decimal IPv4 address, or bracketed hexadecimal IPv6 address; and an explicit port must be between 1 and 65535. Device-managed settings take precedence when managed sources specify different URLs.", + "type": "string", + "minLength": 1, + "anyOf": [ + { + "pattern": "^[Hh][Tt][Tt][Pp][Ss]?://(?=[A-Za-z0-9.-]{1,253}(?::|[/?#]|$))(?=[A-Za-z0-9.-]*[A-Za-z-][A-Za-z0-9-]*\\.?(?::|[/?#]|$))[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\\.?(?::(?!0{1,4}(?:[/?#]|$))(?:[0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(?:[/?#][!-~]*)?$" + }, + { + "pattern": "^[Hh][Tt][Tt][Pp][Ss]?://(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}(?::(?!0{1,4}(?:[/?#]|$))(?:[0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(?:[/?#][!-~]*)?$" + }, + { + "pattern": "^[Hh][Tt][Tt][Pp][Ss]?://\\[(?:(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,7}:|(?:[0-9A-Fa-f]{1,4}:){1,6}:[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,5}(?::[0-9A-Fa-f]{1,4}){1,2}|(?:[0-9A-Fa-f]{1,4}:){1,4}(?::[0-9A-Fa-f]{1,4}){1,3}|(?:[0-9A-Fa-f]{1,4}:){1,3}(?::[0-9A-Fa-f]{1,4}){1,4}|(?:[0-9A-Fa-f]{1,4}:){1,2}(?::[0-9A-Fa-f]{1,4}){1,5}|[0-9A-Fa-f]{1,4}:(?:(?::[0-9A-Fa-f]{1,4}){1,6})|:(?:(?::[0-9A-Fa-f]{1,4}){1,7}|:))\\](?::(?!0{1,4}(?:[/?#]|$))(?:[0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(?:[/?#][!-~]*)?$" + } + ], + "x-composition": { + "strategy": "managed-precedence" + } + } + } } - }, - "additionalProperties": false + } }, - { + "seatbelt": { + "description": "macOS seatbelt policy.", "type": "object", - "required": [ - "source", - "pathPattern" - ], + "additionalProperties": false, "properties": { - "source": { - "const": "pathPattern" - }, - "pathPattern": { - "description": "Regex used to match marketplace paths.", - "type": "string" - } - }, - "additionalProperties": false - } - ] - }, - "x-composition": { - "strategy": "allowlist-intersection" - } - }, - "forceRemoteSettingsRefresh": { - "description": "When true, requires a fresh server-managed settings fetch on startup even when a fresh persistent cache entry exists: the cached policy is never served, and — unlike the default path — it is not used as a fetch-failure fallback either. A failed startup fetch therefore fails closed on the unconfirmed policy (skipping both the 1h fast path and the 24h stale fallback) rather than reverting to a possibly-stale cached policy. The directive applies even when it lives in the cache — a cached server response carrying it forces the next startup to re-fetch. The device (MDM) value takes precedence over a cached server value, so an explicit MDM false overrides a server-side true. Settable via the server-managed policy or the device (MDM) layer. The policy helper cannot set this key. Note: like every typed MDM value, when delivered natively (plist/registry) the boolean must be supplied as a JSON-encoded string (the string \"true\" or \"false\"); a native plist boolean (CFBoolean) or registry DWORD is not accepted. File-based (managed-settings.json) and server-managed documents use a real JSON boolean.", - "type": "boolean" - }, - "policyHelper": { - "description": "Registers an executable that supplies the lowest-priority managed-settings layer. If device and server policy register different executables, the device registration takes priority.", - "type": "object", - "additionalProperties": false, - "required": [ - "path" - ], - "properties": { - "path": { - "description": "Executable registration in one of three forms: an absolute path naming a file, a home-relative path (`~/...` or `~\\...`) with a path after the separator, or a bare program name with no path separator. The registration form is the trust decision: an absolute path pins the exact binary, while a bare name defers to the binary resolved from the user's PATH. The value is used verbatim, so other relative paths, root-only paths, a trailing separator, surrounding whitespace, and any other `~` form (`~`, `~/`, `~bob`) are rejected.", - "type": "string", - "minLength": 1, - "allOf": [ - { - "$comment": "No surrounding whitespace, no trailing separator, and no bare dot component.", - "not": { - "pattern": "^\\s|\\s$|[/\\\\]$|^\\.{1,2}$" - } - }, - { - "$comment": "Absolute (POSIX, Windows drive, or Windows UNC), home-relative, or a bare name carrying no separator and not claiming the reserved `~` prefix.", - "anyOf": [ - { - "pattern": "^/.+" - }, - { - "pattern": "^[A-Za-z]:[/\\\\].+" - }, - { - "pattern": "^\\\\\\\\[^/\\\\]+[/\\\\].+" - }, - { - "pattern": "^~[/\\\\].+" - }, - { - "pattern": "^[^~/\\\\][^/\\\\]*$" + "keychainAccess": { + "description": "Whether keychain access is permitted from within the sandbox. Managed false wins.", + "type": "boolean", + "x-composition": { + "strategy": "deny-wins" } - ] + } } - ] - }, - "args": { - "description": "Arguments passed to the helper executable, in order. At the spawn boundary, the runtime passes each element to the program verbatim as a separate argument without asking a shell to parse another command. Use this to select a subcommand of a helper that serves several callers.", - "type": "array", - "items": { - "type": "string" } - }, - "timeoutMs": { - "description": "Milliseconds the runtime waits for one helper run before it gives up. The helper runs while the session starts, so the value is capped to keep startup bounded.", - "type": "integer", - "minimum": 1, - "maximum": 30000, - "default": 5000 } } }, - "forceLoginOrgs": { - "description": "Pins sign-in to an approved set of GitHub organizations. Each entry is a GitHub organization login (the URL slug, for example 'octocorp'); matching is case-insensitive. When set, Copilot only runs for an account that belongs to at least one listed organization — a personal account, or an account belonging only to some other enterprise, is refused with an actionable error instead of silently escaping enterprise-managed policy. Because it must be able to redirect a developer's *first* sign-in, deploy it via the device channel (MDM plist/registry or managed-settings.json); the server-managed channel only reaches accounts that already authenticated into the organization. The policy helper cannot set this key. The setting also blocks bring-your-own-key (BYOK / API-key) authentication outright, since such credentials carry no GitHub identity whose organization membership could be verified. An empty array means no pin, so the control can be turned off without deleting the key. Fails closed: if the value is present but unusable, or the managed policy that carries it cannot be read from a device that is known to be managed, no account is permitted to sign in until the value is fixed. Note: like every typed MDM value, when delivered natively (plist/registry) the array must be supplied as a JSON-encoded string (for example \"[\\\"octocorp\\\"]\"). File-based (managed-settings.json) and server-managed documents use a real JSON array.", - "type": "array", - "items": { - "type": "string", - "description": "A GitHub organization login: 1-39 characters of ASCII letters and digits, optionally separated by single hyphens. Surrounding whitespace is trimmed. URLs, 'org/team' slugs, and email addresses are rejected.", - "pattern": "^\\s*[A-Za-z0-9](?:-?[A-Za-z0-9]){0,38}\\s*$" - }, - "uniqueItems": true - }, - "policyHelperFailureMode": { - "description": "What happens when the registered policy helper does not produce a usable layer, because it failed to start, timed out, exited non-zero, exceeded the output limit, or emitted output that failed schema validation. Use \"ignore\" (the default) to resolve policy from the remaining channels and report the fault as an error. Use \"failClosed\" to treat the fault as making policy undetermined, which engages the sandbox fail-closed protection. If device and server policy set different values, the device value takes priority.", - "type": "string", - "enum": [ - "ignore", - "failClosed" - ], - "default": "ignore" + "x-composition": { + "strategy": "restrictiveness-merge", + "description": "The managed sandbox is a floor: each field composes with the user's value in the safe direction (force-on for enabled and the MCP/LSP routing flags, deny-wins for the remaining boolean toggles including allowDevToolAccess, intersection for the readwrite/readonly grant lists, union for the deny list). The proxy URL is a directional managed override: it replaces the user's URL while preserving user-managed credentials, and device-managed settings win conflicts between managed sources. Deprecated keys (sandbox.config, sandbox.userPolicy.experimental) are excluded. The runtime applies this per-field merge to compute the effective sandbox policy." + } + } + }, + "$defs": { + "hostPattern": { + "description": "A host rule in standard ASCII DNS, dotted IPv4, or hexadecimal IPv6 notation. Domains may have a leading *. wildcard; * matches every host. No URL, port, userinfo, path, or IPv6 zone identifier is permitted.", + "type": "string", + "minLength": 1, + "not": { + "pattern": "[\\s/@%?#\\\\]" }, - "telemetry": { - "description": "Enterprise-mandated OpenTelemetry (OTel) configuration. Admin-only and non-overridable by users; consumed uniformly by Copilot clients. Controls where Copilot telemetry is exported and whether sensitive content is captured. Operates independently from GitHub's own audit streaming.", - "type": "object", - "additionalProperties": false, - "properties": { - "enabled": { - "description": "Enables OTel emission. When true, users cannot disable telemetry export via local settings or environment variables.", - "type": "boolean" - }, - "endpoint": { - "description": "OTLP collector endpoint. Equivalent to the OTEL_EXPORTER_OTLP_ENDPOINT environment variable; the managed value takes precedence over any user-provided value.", - "type": "string", - "format": "uri" - }, - "filePath": { - "description": "Selects a file destination instead of the collector endpoint and automatically enables OTel unless this managed telemetry block sets enabled to false. Empty or non-string canonical policy values are validation errors. For Copilot runtime and VS Code Agent Host consumers, a relative path is resolved under COPILOT_HOME when that override is nonempty, otherwise under the exporting execution host's default /.copilot directory; a COPILOT_HOME override must be absolute. A leading ~/ is expanded using the exporting execution host's home directory. This path-resolution contract does not claim behavior for server-side or other consumers. Parent directories are created as needed. Path-resolution and file I/O failures are logged and skipped without blocking Copilot or falling back to a network destination.", - "type": "string", - "minLength": 1 - }, - "protocol": { - "description": "OTLP transport protocol used for all exported telemetry. Equivalent to the OTEL_EXPORTER_OTLP_PROTOCOL environment variable; the managed value takes precedence over any user-provided value. Recognized values are \"http/json\" and \"http/protobuf\" (\"grpc\" is accepted for forward compatibility but is not implemented in this version and falls back to the default protocol).", - "type": "string" - }, - "headers": { - "description": "Auth and routing headers sent to the OTLP collector. Equivalent to OTEL_EXPORTER_OTLP_HEADERS. Values are literal strings (no secret interpolation in this version) and are never surfaced in logs or status output. Requires endpoint in the same telemetry block so managed headers are never sent to a destination resolved from user-controlled environment variables. The endpoint must use HTTP or HTTPS. HTTP transmits every header value in plaintext and produces a runtime warning; authentication credentials, including bearer tokens, are confidential and must use HTTPS. Use HTTP only for non-confidential routing metadata on a trusted network.", - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "headersProvider": { - "description": "Either false to disable dynamic telemetry headers, or a shell-free command that produces fresh headers. The executable path follows policy-helper path rules; arguments are passed verbatim without shell evaluation.", - "oneOf": [ - { - "const": false - }, - { - "type": "object", - "additionalProperties": false, - "required": [ - "path" - ], - "properties": { - "path": { - "type": "string", - "minLength": 1, - "allOf": [ - { - "not": { - "pattern": "^\\s|\\s$|[/\\\\]$|^\\.{1,2}$" - } - }, - { - "anyOf": [ - { - "pattern": "^/.+" - }, - { - "pattern": "^[A-Za-z]:[/\\\\].+" - }, - { - "pattern": "^\\\\\\\\[^/\\\\]+[/\\\\].+" - }, - { - "pattern": "^~[/\\\\].+" - }, - { - "pattern": "^[^~/\\\\][^/\\\\]*$" - } - ] - } - ] - }, - "args": { - "type": "array", - "items": { - "type": "string" - }, - "default": [] - }, - "refreshIntervalMs": { - "type": "integer", - "minimum": 1, - "default": 1740000 - } - } - } - ] - }, - "resourceAttributes": { - "description": "Extra OTel resource attributes (e.g. cost center / team routing). Equivalent to OTEL_RESOURCE_ATTRIBUTES.", - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "capture": { - "description": "Fine-grained controls for sensitive OpenTelemetry attributes. Every field defaults to false. Explicit fields override the legacy captureContent shorthand for the same data class.", - "type": "object", - "additionalProperties": false, - "properties": { - "identity": { - "description": "Emits identity attributes for enterprise audit attribution: the authenticated provider account as user.name, the local operating-system account as process.user.name, and the local machine as host.name. Does not affect enduser.pseudo.id.", - "type": "boolean", - "default": false - }, - "prompts": { - "description": "Captures user, system, and developer prompt content and associated input context, including supported attachments.", - "type": "boolean", - "default": false - }, - "responses": { - "description": "Captures assistant response and supported reasoning content.", - "type": "boolean", - "default": false - }, - "toolArguments": { - "description": "Captures raw tool arguments, tool descriptions and schemas, and sensitive tool or MCP identifiers.", - "type": "boolean", - "default": false - }, - "toolOutput": { - "description": "Captures tool result content and execution error detail.", - "type": "boolean", - "default": false - }, - "policyDetail": { - "description": "Captures raw policy details such as sandbox filesystem paths. Bounded policy posture, counts, outcomes, and classifications remain available when false.", - "type": "boolean", - "default": false - } - } - }, - "captureContent": { - "description": "Legacy shorthand for prompts, responses, toolArguments, toolOutput, and policyDetail. Does not enable identity. Explicit capture fields override this value for their class. Defaults to false.", - "type": "boolean" - }, - "lockCaptureContent": { - "description": "When true, prevents local settings from enabling a legacy content class unless that class or captureContent is explicitly set in this managed telemetry block. Does not govern identity.", - "type": "boolean" - }, - "serviceName": { - "description": "Overrides the OTel service.name resource attribute. Equivalent to OTEL_SERVICE_NAME.", - "type": "string" - } + "anyOf": [ + { + "const": "*" }, - "x-composition": { - "strategy": "highest-priority-managed-block-wins", - "description": "The highest-priority managed channel that defines telemetry supplies the complete managed telemetry block; weaker managed telemetry blocks do not merge into it. Explicit managed fields are authoritative over local or environment configuration. Omitted fields fall back according to the client resolver, including legacy captureContent and lockCaptureContent compatibility." + { + "pattern": "^(?:\\*\\.)?(?!(?:.*\\.)?(?:[0-9]+|0[xX][0-9A-Fa-f]*)\\.*$)[A-Za-z0-9_-]+(?:\\.[A-Za-z0-9_-]+)*\\.*$" }, - "allOf": [ - { - "if": { - "properties": { - "headersProvider": { - "type": "object" - } - }, - "required": [ - "headersProvider" - ] + { + "pattern": "^(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}\\.*$" + }, + { + "allOf": [ + { + "pattern": "^(?:[^\\[\\]]*|\\[[^\\[\\]]*\\])$" }, - "then": { - "required": [ - "endpoint" - ] - } - } - ] - }, - "sandbox": { - "description": "Enterprise-mandated sandbox floor. Admin-set values compose with the user's own sandbox configuration in the safe (more-restrictive) direction and cannot be loosened by the user. Only non-deprecated keys are part of the managed surface. The restrictiveness merge itself is applied by the client at resolve time (see #11374); recognizing the block here is behavior-neutral.", - "type": "object", - "additionalProperties": false, - "properties": { - "enabled": { - "description": "Force sandboxing on by default. When a managed source sets true, ordinary user configuration, startup flags, and sandbox settings cannot disable the sandbox. If the effective policy also allows bypass, the user may explicitly disable it for the current session from an active sandbox-bypass permission prompt.", - "type": "boolean", - "x-composition": { - "strategy": "force-on-wins" - } - }, - "failIfUnavailable": { - "description": "When true and the composed managed policy also sets sandbox.enabled to true, the managed sandbox becomes mandatory. Copilot must block model and tool execution if the policy cannot be validated, compiled, or enforced by a usable sandbox backend, and the user cannot disable it. This control does not enable sandboxing by itself; true from any managed source wins.", - "type": "boolean", - "x-composition": { - "strategy": "force-on-wins" - } - }, - "allowBypass": { - "description": "Whether tools may offer the user a per-operation sandbox bypass and whether the user may explicitly disable the sandbox for the current session from an active bypass permission prompt. Managed false wins: if policy forbids bypass, the user cannot re-enable either escape hatch.", - "type": "boolean", - "x-composition": { - "strategy": "deny-wins" - } - }, - "addCurrentWorkingDirectory": { - "description": "Whether the current working directory is auto-added to the sandbox readwrite paths. Managed false wins.", - "type": "boolean", - "x-composition": { - "strategy": "deny-wins" - } - }, - "sandboxMcpServers": { - "description": "Whether MCP servers are spawned inside the sandbox. Routing flag: true (servers sandboxed) is the more-restrictive value, so a managed true wins (force-on) and a user false cannot loosen it.", - "type": "boolean", - "x-composition": { - "strategy": "force-on-wins" + { + "pattern": "^\\[?(?:(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,7}:|(?:[0-9A-Fa-f]{1,4}:){1,6}:[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,5}(?::[0-9A-Fa-f]{1,4}){1,2}|(?:[0-9A-Fa-f]{1,4}:){1,4}(?::[0-9A-Fa-f]{1,4}){1,3}|(?:[0-9A-Fa-f]{1,4}:){1,3}(?::[0-9A-Fa-f]{1,4}){1,4}|(?:[0-9A-Fa-f]{1,4}:){1,2}(?::[0-9A-Fa-f]{1,4}){1,5}|[0-9A-Fa-f]{1,4}:(?:(?::[0-9A-Fa-f]{1,4}){1,6})|:(?:(?::[0-9A-Fa-f]{1,4}){1,7}|:))\\]?$" } - }, - "sandboxLspServers": { - "description": "Whether LSP servers are spawned inside the sandbox. Routing flag: true (servers sandboxed) is the more-restrictive value, so a managed true wins (force-on) and a user false cannot loosen it.", - "type": "boolean", - "x-composition": { - "strategy": "force-on-wins" + ] + } + ] + }, + "mcpDenyMatcher": { + "description": "A single MCP denylist entry. Exactly one of serverUrl, serverCommand, or serverName must be present.", + "type": "object", + "oneOf": [ + { + "type": "object", + "required": [ + "serverUrl" + ], + "properties": { + "serverUrl": { + "description": "Matches a remote (HTTP/SSE) server by URL. Supports '*' wildcards anywhere; the scheme and host are matched case-insensitively, the path case-sensitively. A pattern with no path matches any path. ${VAR} references expand before matching.", + "type": "string", + "minLength": 1 } }, - "auth": { - "description": "Credential-injection capabilities applied while the sandbox is enabled.", - "type": "object", - "additionalProperties": false, - "properties": { - "git": { - "description": "Whether a git token is injected so git works inside the sandbox. Credential-injection capability; managed false wins: policy can force it off but never force it on.", - "type": "boolean", - "x-composition": { - "strategy": "deny-wins" - } - }, - "gh": { - "description": "Whether a gh token is injected so gh works inside the sandbox. Credential-injection capability; managed false wins: policy can force it off but never force it on.", - "type": "boolean", - "x-composition": { - "strategy": "deny-wins" - } + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "serverCommand" + ], + "properties": { + "serverCommand": { + "description": "Matches a stdio server by its exact command and arguments (every element, in order). ${VAR} references expand before matching.", + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 } } }, - "allowDevToolAccess": { - "description": "Whether the sandbox automatically grants access to developer-tool files needed by builds, including the tool directories discovered on PATH and in toolchain environment variables (GOROOT, CARGO_HOME, JAVA_HOME, and similar), the user-profile tool directories, plus caches, registries, toolchains, and registry/proxy configuration in default home locations. Delivering false disables all of them, so a developer-tool path must then be granted explicitly through sandbox.userPolicy.filesystem. This governs only the developer-tool grants: the working directory (sandbox.addCurrentWorkingDirectory), temporary storage, session log paths, and the platform's system locations — which on macOS include the /usr/local and /opt/homebrew prefixes — are granted by their own rules and are unaffected. Managed false wins: policy can disable these grants but never force them on", - "type": "boolean", - "x-composition": { - "strategy": "deny-wins" - } - }, - "learningMode": { - "description": "Windows only. The denial-capture mode sandboxed shell commands start in when the sandbox is enabled: \"deny\" records and denies blocked access (the default), and \"allow\" records and allows filesystem and process access (permissive learning mode) while the network policy still enforces. With \"allow\", a command that is still refused offers only the full sandbox bypass, and only when sandbox.allowBypass permits it. \"deny\" is the stricter value and wins when managed sources disagree. Deliver \"allow\" through native MDM (the Windows registry policy hive, for example through Intune); an \"allow\" in managed-settings.json is ignored. Ignored on hosts without denial capture", - "type": "string", - "enum": [ - "deny", - "allow" - ], - "x-composition": { - "strategy": "enum-order-wins", - "description": "deny wins when managed sources disagree." + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "serverName" + ], + "properties": { + "serverName": { + "description": "Matches a server by its assigned label (exact, no wildcards). Any non-empty string is accepted in a denylist, so connectors can be blocked by display name.", + "type": "string", + "minLength": 1 } }, - "userPolicy": { - "description": "Fine-grained sandbox policy (filesystem, network, seatbelt). Deprecated sub-keys (userPolicy.experimental) are excluded from the managed surface.", - "type": "object", - "additionalProperties": false, - "properties": { - "filesystem": { - "description": "Filesystem access policy. Grant lists shrink under policy (intersection); the deny list grows (union).", - "type": "object", - "additionalProperties": false, - "properties": { - "readwritePaths": { - "description": "Paths granted read-write access. Managed policy can only shrink the effective grant (intersection).", - "type": "array", - "items": { - "type": "string" - }, - "x-composition": { - "strategy": "allowlist-intersection" - } - }, - "readonlyPaths": { - "description": "Paths granted read-only access. Managed policy can only shrink the effective grant (intersection).", - "type": "array", - "items": { - "type": "string" - }, - "x-composition": { - "strategy": "allowlist-intersection" - } - }, - "deniedPaths": { - "description": "Paths explicitly denied. Managed policy can only add denials (union).", - "type": "array", - "items": { - "type": "string" - }, - "x-composition": { - "strategy": "denylist-union" - } - } - } - }, - "network": { - "description": "Network access policy. Managed false wins for each toggle. Non-empty host allowlists intersect; denylists combine and take precedence. A managed proxy URL overrides the user's URL while credentials remain user-managed.", - "type": "object", - "additionalProperties": false, - "properties": { - "allowOutbound": { - "description": "Whether outbound network access is permitted. Managed false wins.", - "type": "boolean", - "x-composition": { - "strategy": "deny-wins" - } - }, - "allowLocalNetwork": { - "description": "Whether local-network access is permitted. Managed false wins.", - "type": "boolean", - "x-composition": { - "strategy": "deny-wins" - } - }, - "allowedHosts": { - "description": "Host allowlist for the built-in local proxy. Non-empty lists intersect with other configured allowlists; absent or empty lists add no restriction. Supports exact hosts, IP addresses, and *.example.com for strict subdomains.", - "type": "array", - "items": { - "$ref": "#/$defs/hostPattern" - }, - "x-composition": { - "strategy": "allowlist-intersection" - } - }, - "blockedHosts": { - "description": "Host denylist for the built-in local proxy. Combined with other denylists; deny takes precedence over allow. A domain also denies all its subdomains. IP addresses match exactly; *.example.com matches strict subdomains, and * denies every host.", - "type": "array", - "items": { - "$ref": "#/$defs/hostPattern" - }, - "x-composition": { - "strategy": "denylist-union" - } - }, - "proxy": { - "description": "Enterprise-managed sandbox proxy endpoint. Only the URL is managed; username and password remain user-managed and are not admitted by this schema.", - "type": "object", - "additionalProperties": false, - "required": [ - "url" - ], - "properties": { - "url": { - "description": "Credential-free HTTP(S) proxy endpoint enforced over the user's configured proxy URL. The URL must contain only printable ASCII characters (percent-encode non-ASCII path data); the host must be an ASCII DNS hostname, dotted-decimal IPv4 address, or bracketed hexadecimal IPv6 address; and an explicit port must be between 1 and 65535. Device-managed settings take precedence when managed sources specify different URLs.", - "type": "string", - "minLength": 1, - "anyOf": [ - { - "pattern": "^[Hh][Tt][Tt][Pp][Ss]?://(?=[A-Za-z0-9.-]{1,253}(?::|[/?#]|$))(?=[A-Za-z0-9.-]*[A-Za-z-][A-Za-z0-9-]*\\.?(?::|[/?#]|$))[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\\.?(?::(?!0{1,4}(?:[/?#]|$))(?:[0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(?:[/?#][!-~]*)?$" - }, - { - "pattern": "^[Hh][Tt][Tt][Pp][Ss]?://(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}(?::(?!0{1,4}(?:[/?#]|$))(?:[0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(?:[/?#][!-~]*)?$" - }, - { - "pattern": "^[Hh][Tt][Tt][Pp][Ss]?://\\[(?:(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,7}:|(?:[0-9A-Fa-f]{1,4}:){1,6}:[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,5}(?::[0-9A-Fa-f]{1,4}){1,2}|(?:[0-9A-Fa-f]{1,4}:){1,4}(?::[0-9A-Fa-f]{1,4}){1,3}|(?:[0-9A-Fa-f]{1,4}:){1,3}(?::[0-9A-Fa-f]{1,4}){1,4}|(?:[0-9A-Fa-f]{1,4}:){1,2}(?::[0-9A-Fa-f]{1,4}){1,5}|[0-9A-Fa-f]{1,4}:(?:(?::[0-9A-Fa-f]{1,4}){1,6})|:(?:(?::[0-9A-Fa-f]{1,4}){1,7}|:))\\](?::(?!0{1,4}(?:[/?#]|$))(?:[0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(?:[/?#][!-~]*)?$" - } - ], - "x-composition": { - "strategy": "managed-precedence" - } - } - } - } - } - }, - "seatbelt": { - "description": "macOS seatbelt policy.", - "type": "object", - "additionalProperties": false, - "properties": { - "keychainAccess": { - "description": "Whether keychain access is permitted from within the sandbox. Managed false wins.", - "type": "boolean", - "x-composition": { - "strategy": "deny-wins" - } - } - } - } - } - } - }, - "x-composition": { - "strategy": "restrictiveness-merge", - "description": "The managed sandbox is a floor: each field composes with the user's value in the safe direction (force-on for enabled and the MCP/LSP routing flags, deny-wins for the remaining boolean toggles including allowDevToolAccess, intersection for the readwrite/readonly grant lists, union for the deny list). The proxy URL is a directional managed override: it replaces the user's URL while preserving user-managed credentials, and device-managed settings win conflicts between managed sources. Deprecated keys (sandbox.config, sandbox.userPolicy.experimental) are excluded. The runtime applies this per-field merge to compute the effective sandbox policy." + "additionalProperties": false } - } + ] }, - "$defs": { - "hostPattern": { - "description": "A host rule in standard ASCII DNS, dotted IPv4, or hexadecimal IPv6 notation. Domains may have a leading *. wildcard; * matches every host. No URL, port, userinfo, path, or IPv6 zone identifier is permitted.", - "type": "string", - "minLength": 1, - "not": { - "pattern": "[\\s/@%?#\\\\]" - }, - "anyOf": [ - { - "const": "*" - }, - { - "pattern": "^(?:\\*\\.)?(?!(?:.*\\.)?(?:[0-9]+|0[xX][0-9A-Fa-f]*)\\.*$)[A-Za-z0-9_-]+(?:\\.[A-Za-z0-9_-]+)*\\.*$" - }, - { - "pattern": "^(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}\\.*$" - }, - { - "allOf": [ - { - "pattern": "^(?:[^\\[\\]]*|\\[[^\\[\\]]*\\])$" - }, - { - "pattern": "^\\[?(?:(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,7}:|(?:[0-9A-Fa-f]{1,4}:){1,6}:[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,5}(?::[0-9A-Fa-f]{1,4}){1,2}|(?:[0-9A-Fa-f]{1,4}:){1,4}(?::[0-9A-Fa-f]{1,4}){1,3}|(?:[0-9A-Fa-f]{1,4}:){1,3}(?::[0-9A-Fa-f]{1,4}){1,4}|(?:[0-9A-Fa-f]{1,4}:){1,2}(?::[0-9A-Fa-f]{1,4}){1,5}|[0-9A-Fa-f]{1,4}:(?:(?::[0-9A-Fa-f]{1,4}){1,6})|:(?:(?::[0-9A-Fa-f]{1,4}){1,7}|:))\\]?$" - } - ] - } - ] - }, - "mcpDenyMatcher": { - "description": "A single MCP denylist entry. Exactly one of serverUrl, serverCommand, or serverName must be present.", - "type": "object", - "oneOf": [ - { - "type": "object", - "required": [ - "serverUrl" - ], - "properties": { - "serverUrl": { - "description": "Matches a remote (HTTP/SSE) server by URL. Supports '*' wildcards anywhere; the scheme and host are matched case-insensitively, the path case-sensitively. A pattern with no path matches any path. ${VAR} references expand before matching.", - "type": "string", - "minLength": 1 - } - }, - "additionalProperties": false - }, - { - "type": "object", - "required": [ - "serverCommand" - ], - "properties": { - "serverCommand": { - "description": "Matches a stdio server by its exact command and arguments (every element, in order). ${VAR} references expand before matching.", - "type": "array", - "minItems": 1, - "items": { - "type": "string", - "minLength": 1 - } - } - }, - "additionalProperties": false + "mcpAllowMatcher": { + "description": "A single MCP allowlist entry. Exactly one of serverUrl, serverCommand, or serverName must be present. Identical to a denylist entry except serverName is restricted to a safe charset so it cannot smuggle a wildcard past the allowlist.", + "type": "object", + "oneOf": [ + { + "type": "object", + "required": [ + "serverUrl" + ], + "properties": { + "serverUrl": { + "description": "Matches a remote (HTTP/SSE) server by URL. Supports '*' wildcards anywhere; the scheme and host are matched case-insensitively, the path case-sensitively. A pattern with no path matches any path. ${VAR} references expand before matching.", + "type": "string", + "minLength": 1 + } }, - { - "type": "object", - "required": [ - "serverName" - ], - "properties": { - "serverName": { - "description": "Matches a server by its assigned label (exact, no wildcards). Any non-empty string is accepted in a denylist, so connectors can be blocked by display name.", - "type": "string", - "minLength": 1 - } - }, - "additionalProperties": false - } - ] - }, - "mcpAllowMatcher": { - "description": "A single MCP allowlist entry. Exactly one of serverUrl, serverCommand, or serverName must be present. Identical to a denylist entry except serverName is restricted to a safe charset so it cannot smuggle a wildcard past the allowlist.", - "type": "object", - "oneOf": [ - { - "type": "object", - "required": [ - "serverUrl" - ], - "properties": { - "serverUrl": { - "description": "Matches a remote (HTTP/SSE) server by URL. Supports '*' wildcards anywhere; the scheme and host are matched case-insensitively, the path case-sensitively. A pattern with no path matches any path. ${VAR} references expand before matching.", + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "serverCommand" + ], + "properties": { + "serverCommand": { + "description": "Matches a stdio server by its exact command and arguments (every element, in order). ${VAR} references expand before matching.", + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } - }, - "additionalProperties": false + } }, - { - "type": "object", - "required": [ - "serverCommand" - ], - "properties": { - "serverCommand": { - "description": "Matches a stdio server by its exact command and arguments (every element, in order). ${VAR} references expand before matching.", - "type": "array", - "minItems": 1, - "items": { - "type": "string", - "minLength": 1 - } - } - }, - "additionalProperties": false + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "serverName" + ], + "properties": { + "serverName": { + "description": "Matches a server by its assigned label (exact, case-sensitive, no wildcards or normalization). Supports registry names such as microsoft/playwright-mcp and io.github.github/github-mcp-server. Restricted to ASCII letters, numbers, hyphens, underscores, dots, and forward slashes.", + "type": "string", + "pattern": "^[A-Za-z0-9_./-]+$" + } }, - { - "type": "object", - "required": [ - "serverName" - ], - "properties": { - "serverName": { - "description": "Matches a server by its assigned label (exact, case-sensitive, no wildcards or normalization). Supports registry names such as microsoft/playwright-mcp and io.github.github/github-mcp-server. Restricted to ASCII letters, numbers, hyphens, underscores, dots, and forward slashes.", - "type": "string", - "pattern": "^[A-Za-z0-9_./-]+$" - } - }, - "additionalProperties": false - } - ] - } + "additionalProperties": false + } + ] } - }, - "runtimeVersion": "1.0.100-1" + } } diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts index fe94a8ec2a5ea9..be993091c29e79 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts @@ -5,14 +5,15 @@ import assert from 'assert'; import { DeferredPromise, timeout } from '../../../../base/common/async.js'; -import { Event } from '../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; import { constObservable } from '../../../../base/common/observable.js'; import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { mock } from '../../../../base/test/common/mock.js'; import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; import { NullLogService } from '../../../log/common/log.js'; -import { AgentSession } from '../../common/agent.js'; +import { AgentSession, type IAgent } from '../../common/agent.js'; import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind } from '../../common/agentHostTelemetry.js'; import { buildBranchChangesetUri, buildDefaultChangesetCatalog, buildSessionChangesetUri, buildTurnChangesetUri, buildUncommittedChangesetUri, buildFolderChangesetOwnerUri } from '../../common/changesetUri.js'; @@ -41,6 +42,7 @@ import { AgentHostDatabase } from '../../node/agentHostDatabase.js'; import { IAgentHostSessionStateStore, SessionStateMirrorFlush, sessionStatePatch } from '../../common/agentHostSessionStateStore.js'; import { createLegacyChatMetadataPersistence, createTestSessionStateStore } from './chatMetadataTestHelpers.js'; import { createTestChatOwnershipService } from './chatOwnershipTestUtils.js'; +import { createTestAgentHostProviderService } from './testAgentHostProviderService.js'; type WithoutLast = T extends [...infer Head, unknown] ? Head : never; type WithoutLastThree = WithoutLast>>; @@ -81,15 +83,24 @@ class TestAgentHostChangesetService extends AgentHostChangesetService { } readonly stateStore: IAgentHostSessionStateStore; + readonly provider: IAgent; + readonly storedTurnEdits: Emitter<{ readonly chat: URI; readonly turnIds: readonly string[] }>; - constructor(...args: WithoutLast>>>) { + constructor(...args: WithoutLastThree>>) { const mirrorStore = createTestSessionStateStore(new AgentHostDatabase(':memory:'), args[2]); + const storedTurnEdits = new Emitter<{ readonly chat: URI; readonly turnIds: readonly string[] }>(); + const provider = new class extends mock() { + override readonly onDidStoreTurnEdits = storedTurnEdits.event; + override waitForTurnEdits = async (_chat: URI, _turnId: string): Promise => { }; + }; super(...args, TEST_GIT_STATE_SERVICE, new NullAgentHostWorktreeIsolation(), { _serviceBrand: undefined, setRead: async () => { }, setArchived: async () => { }, ...createLegacyChatMetadataPersistence(args[2]), }, createTestChatOwnershipService(args[0]), - mirrorStore); + mirrorStore, { ...createTestAgentHostProviderService(() => provider), getProviders: () => [provider] }); this.stateStore = mirrorStore; + this.provider = provider; + this.storedTurnEdits = this._register(storedTurnEdits); settleServices.add(this); } } @@ -1861,6 +1872,58 @@ suite('AgentHostChangesetService - multi-root and recomputation', () => { }); }); + test('turn changesets wait for the exact chat provider to persist native snapshots', async () => { + const db = new TestSessionDatabase(); + const { svc, stateManager } = build({ workingDirectories: ['file:///repo'], git: createNoopGitService(), checkpoint: NULL_CHECKPOINT_SERVICE, db }); + const started = new DeferredPromise(); + const persisted = new DeferredPromise(); + const calls: string[][] = []; + svc.provider.waitForTurnEdits = async (chat, turnId) => { + calls.push([chat.toString(), turnId]); + started.complete(); + await persisted.p; + }; + const result = svc.computeTurnChangeset(sessionStr, 'cancelled-turn', 'fileEditTracker'); + await started.p; + const during = stateManager.getChangesetState(buildTurnChangesetUri(sessionStr, 'cancelled-turn'))?.status; + db.addEdit({ + turnId: 'cancelled-turn', toolCallId: 'native-edit', filePath: '/repo/file.ts', kind: FileEditKind.Edit, + beforeContent: encodeString('before\n'), afterContent: encodeString('after\n'), addedLines: 1, removedLines: 1 + }); + persisted.complete(); + const state = stateManager.getChangesetState(await result); + assert.deepStrictEqual({ during, calls, status: state?.status, files: state?.files.map(file => file.id) }, { + during: ChangesetStatus.Computing, calls: [[buildDefaultChatUri(sessionStr), 'cancelled-turn']], + status: ChangesetStatus.Ready, files: ['file:///repo/file.ts'], + }); + }); + + test('late snapshots refresh session changes without waiting for a native turn', async () => { + const db = new TestSessionDatabase(); + const { svc, stateManager } = build({ workingDirectories: ['file:///repo'], git: createNoopGitService(), checkpoint: NULL_CHECKPOINT_SERVICE, db }); + svc.provider.waitForTurnEdits = async () => { throw new Error('Session aggregates must not wait for native turns'); }; + const changeset = buildSessionChangesetUri(sessionStr); + const initial = new DeferredPromise(); + const updated = new DeferredPromise(); + disposables.add(stateManager.onDidEmitEnvelope(envelope => { + if (envelope.channel === changeset && envelope.action.type === ActionType.ChangesetStatusChanged && envelope.action.status === ChangesetStatus.Ready) { + if (stateManager.getChangesetState(changeset)?.files.length) { updated.complete(); } + else { initial.complete(); } + } + })); + svc.refreshSessionChangeset(sessionStr, 'fileEditTracker'); + await initial.p; + db.addEdit({ + turnId: 'cancelled-turn', toolCallId: 'native-edit', filePath: '/repo/file.ts', kind: FileEditKind.Edit, + beforeContent: encodeString('before\n'), afterContent: encodeString('after\n'), addedLines: 1, removedLines: 1 + }); + svc.storedTurnEdits.fire({ chat: URI.parse(buildDefaultChatUri(sessionStr)), turnIds: ['cancelled-turn'] }); + await updated.p; + assert.deepStrictEqual({ status: stateManager.getChangesetState(changeset)?.status, files: stateManager.getChangesetState(changeset)?.files.map(file => file.id) }, { + status: ChangesetStatus.Ready, files: ['file:///repo/file.ts'], + }); + }); + test('aggregates turn diffs across all folders of a multi-root session', async () => { const git = createNoopGitService(); git.getRepositoryRoot = async wd => URI.parse(wd.toString()); diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetStrategy.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetStrategy.test.ts index 56ca93cc6e5f5b..c728268a658192 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetStrategy.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetStrategy.test.ts @@ -41,6 +41,7 @@ import { createNoopGitService, createSessionDataService, encodeString, TestDiffC import { AgentHostDatabase } from '../../node/agentHostDatabase.js'; import { createLegacyChatMetadataPersistence, createTestSessionStateStore } from './chatMetadataTestHelpers.js'; import { createTestChatOwnershipService } from './chatOwnershipTestUtils.js'; +import { createTestAgentHostProviderService } from './testAgentHostProviderService.js'; suite('AgentHostChangesetStrategy', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -189,6 +190,7 @@ suite('AgentHostChangesetStrategy', () => { { _serviceBrand: undefined, setRead: async () => { }, setArchived: async () => { }, ...createLegacyChatMetadataPersistence(data) }, createTestChatOwnershipService(state), sessionStateStore, + createTestAgentHostProviderService(() => undefined), )); state.createSession({ resource: session, diff --git a/src/vs/platform/agentHost/test/node/agentHostLocalTurns.test.ts b/src/vs/platform/agentHost/test/node/agentHostLocalTurns.test.ts index 90177b22e5ed37..769591cb2289d8 100644 --- a/src/vs/platform/agentHost/test/node/agentHostLocalTurns.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostLocalTurns.test.ts @@ -9,7 +9,7 @@ import { URI } from '../../../../base/common/uri.js'; import { NullLogService } from '../../../log/common/log.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { MessageKind, TurnState, type Turn } from '../../common/state/sessionState.js'; -import { AgentHostLocalTurns } from '../../node/agentHostLocalTurns.js'; +import { AgentHostLocalTurns, interleaveLocalTurns } from '../../node/agentHostLocalTurns.js'; import { TestSessionDatabase, createSessionDataService } from '../common/sessionTestHelpers.js'; suite('AgentHostLocalTurns', () => { @@ -93,4 +93,20 @@ suite('AgentHostLocalTurns', () => { { kind: 'local', turnId: 'local-b', seq: 5 }, ]); }); + + test('restores local anchors against visible host or native turns without changing persisted anchors', async () => { + const db = new TestSessionDatabase(); + const chat = 'ahp-chat://default/a'; + await db.setTurnEventId('host', 'native'); + await db.insertPersistedTurn({ kind: 'local', turnId: 'local', chatUri: chat, anchorTurnId: 'host', payload: JSON.stringify(turn('local')) }); + const registry = new AgentHostLocalTurns(createTestChatRows(createSessionDataService(db), disposables), new NullLogService()); + for (const visible of ['native', 'host', 'unrelated']) { + const records = await registry.loadForChat(session, chat, new Set([visible])); + assert.deepStrictEqual({ + turns: interleaveLocalTurns([turn(visible)], records, new NullLogService()).map(turn => turn.id), + anchor: registry.resolveConcreteTurnId(chat, 'local'), + }, visible === 'unrelated' ? { turns: ['unrelated'], anchor: 'host' } : { turns: [visible, 'local'], anchor: visible }); + } + assert.strictEqual((await db.getPersistedTurns())[0].anchorTurnId, 'host'); + }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostSessionStateOwnershipGuard.test.ts b/src/vs/platform/agentHost/test/node/agentHostSessionStateOwnershipGuard.test.ts index 2b57aeef89bb6e..aff0fbe33f75a5 100644 --- a/src/vs/platform/agentHost/test/node/agentHostSessionStateOwnershipGuard.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostSessionStateOwnershipGuard.test.ts @@ -125,8 +125,8 @@ const ALLOWLIST: readonly IAllowance[] = [ { file: 'copilot/copilotAgent.ts', key: 'AH_META_IS_DONE_DB_KEY', count: 1, removedBy: 'permanent', reason: 'resume reads the session\'s legacy archived state through _openMetadata: the store, never importing' }, { file: 'copilot/copilotAgent.ts', key: '_META_CUSTOMIZATION_DIRECTORY', count: 3, removedBy: 'permanent', reason: 'working-directory and stored-metadata reads through _openMetadata: the store for a session, a peer backing\'s own db' }, { file: 'copilot/copilotAgent.ts', key: '_META_SHARED_CUSTOMIZATION_DIRECTORIES', count: 1, removedBy: 'permanent', reason: 'working-directory snapshot read through _openMetadata (rollback, and older builds still read the value): the store for a session, a peer backing\'s own db' }, - { file: 'copilot/copilotAgent.ts', key: '_META_PROJECT_URI', count: 2, removedBy: 'permanent', reason: 'reads through _openMetadata: the store for a session, a peer backing\'s own db' }, - { file: 'copilot/copilotAgent.ts', key: '_META_PROJECT_DISPLAY_NAME', count: 2, removedBy: 'permanent', reason: 'reads through _openMetadata: the store for a session, a peer backing\'s own db' }, + { file: 'copilot/copilotAgent.ts', key: '_META_PROJECT_URI', count: 3, removedBy: 'permanent', reason: 'reads through _openMetadata: the store for a session, a peer backing\'s own db' }, + { file: 'copilot/copilotAgent.ts', key: '_META_PROJECT_DISPLAY_NAME', count: 3, removedBy: 'permanent', reason: 'reads through _openMetadata: the store for a session, a peer backing\'s own db' }, { file: 'copilot/copilotAgent.ts', key: '_META_PROJECT_RESOLVED', count: 2, removedBy: 'permanent', reason: 'reads through _openMetadata: the store for a session, a peer backing\'s own db' }, // Legacy-catalog (v1) membership: the peer store is its writer, with its own revision tracking, as on main (group 5). { file: 'agentHostPeerChatStore.ts', key: 'PEER_CHATS_METADATA_KEY', count: 4, removedBy: 'permanent', reason: 'peerChats read and written by its owner, the peer store: as on main for legacy catalogs; the older-build copy of normalized ones, read back before it is overwritten (group 5)' }, diff --git a/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts index 4735c2d4a397f7..2f84e96387d0ff 100644 --- a/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts @@ -41,6 +41,7 @@ import { AgentHostDatabase } from '../../node/agentHostDatabase.js'; import { AgentSessionRegistry, IAgentSessionRegistry } from '../../node/agentSessionRegistry.js'; import { AdditionalWorktreeLifecycleService, IAdditionalWorktreeLifecycleService } from '../../node/chatContributions/additionalWorktreeLifecycle/additionalWorktreeLifecycleService.js'; import { ISessionWorkspaceConversionService } from '../../node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.js'; +import { IAgentHostScheduledChatMoves } from '../../node/chatContributions/scheduledChatMoves/scheduledChatMovesService.js'; import { IAgentHostArchivedChatWorkspaceService } from '../../node/chatContributions/chatArchive/archivedChatWorkspaceService.js'; import { IAgentHostProviderService } from '../../node/agentHostProviderService.js'; import { createTestAgentHostProviderService } from './testAgentHostProviderService.js'; @@ -329,6 +330,7 @@ suite('AgentSideEffects — tool call telemetry', () => { cancel: () => { }, updateSessionWorkspace: async () => { }, } satisfies ISessionWorkspaceConversionService], + [IAgentHostScheduledChatMoves, { _serviceBrand: undefined, schedule: () => { }, turnEnded: () => { } } satisfies IAgentHostScheduledChatMoves], [IAgentHostArchivedChatWorkspaceService, { _serviceBrand: undefined, setChatWorkspaceAttached: async () => { } } satisfies IAgentHostArchivedChatWorkspaceService], ); const instantiationService = disposables.add(new InstantiationService(services, /*strict*/ true)); diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts index 225828e85a9b2a..ed43d2c4c48083 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts @@ -41,6 +41,7 @@ import { AgentHostDatabase } from '../../node/agentHostDatabase.js'; import { AgentSessionRegistry, IAgentSessionRegistry } from '../../node/agentSessionRegistry.js'; import { AdditionalWorktreeLifecycleService, IAdditionalWorktreeLifecycleService } from '../../node/chatContributions/additionalWorktreeLifecycle/additionalWorktreeLifecycleService.js'; import { ISessionWorkspaceConversionService } from '../../node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.js'; +import { IAgentHostScheduledChatMoves } from '../../node/chatContributions/scheduledChatMoves/scheduledChatMovesService.js'; import { IAgentHostArchivedChatWorkspaceService } from '../../node/chatContributions/chatArchive/archivedChatWorkspaceService.js'; import { IAgentHostProviderService } from '../../node/agentHostProviderService.js'; import { createTestAgentHostProviderService } from './testAgentHostProviderService.js'; @@ -286,6 +287,7 @@ suite('AgentSideEffects — turn hang telemetry', () => { cancel: () => { }, updateSessionWorkspace: async () => { }, } satisfies ISessionWorkspaceConversionService], + [IAgentHostScheduledChatMoves, { _serviceBrand: undefined, schedule: () => { }, turnEnded: () => { } } satisfies IAgentHostScheduledChatMoves], [IAgentHostArchivedChatWorkspaceService, { _serviceBrand: undefined, setChatWorkspaceAttached: async () => { } } satisfies IAgentHostArchivedChatWorkspaceService], ); const instantiationService = disposables.add(new InstantiationService(services, /*strict*/ true)); diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts index b670a929b4c3ce..46f6651e2e2eb2 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts @@ -49,6 +49,7 @@ import { AgentHostDatabase } from '../../node/agentHostDatabase.js'; import { AgentSessionRegistry, IAgentSessionRegistry } from '../../node/agentSessionRegistry.js'; import { AdditionalWorktreeLifecycleService, IAdditionalWorktreeLifecycleService } from '../../node/chatContributions/additionalWorktreeLifecycle/additionalWorktreeLifecycleService.js'; import { ISessionWorkspaceConversionService } from '../../node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.js'; +import { IAgentHostScheduledChatMoves } from '../../node/chatContributions/scheduledChatMoves/scheduledChatMovesService.js'; import { IAgentHostArchivedChatWorkspaceService } from '../../node/chatContributions/chatArchive/archivedChatWorkspaceService.js'; import { IAgentHostProviderService } from '../../node/agentHostProviderService.js'; import { createTestAgentHostProviderService } from './testAgentHostProviderService.js'; @@ -324,6 +325,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { cancel: () => { }, updateSessionWorkspace: async () => { }, } satisfies ISessionWorkspaceConversionService], + [IAgentHostScheduledChatMoves, { _serviceBrand: undefined, schedule: () => { }, turnEnded: () => { } } satisfies IAgentHostScheduledChatMoves], [IAgentHostArchivedChatWorkspaceService, { _serviceBrand: undefined, setChatWorkspaceAttached: async () => { } } satisfies IAgentHostArchivedChatWorkspaceService], ); const instantiationService = disposables.add(new InstantiationService(services, /*strict*/ true)); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 7281abd7c787b1..5bc8e3c3876a86 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -4,6 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import { AgentChatMoveRefusal, AgentChatMoveRefusedError, ChatMoveConfigKey } from '../../common/agentHostChatMove.js'; +import type { IAgentServiceSessionServerToolAccessor } from '../../node/shared/sessionServerTools.js'; +import { createMoveSessionLookup, getMoveSessionArgs } from '../../node/shared/sessionMoveTools.js'; +import { buildOpenSessionLinkUri } from '../../common/openSessionLink.js'; import { isShapedChatUri } from '../../common/agentHostChatIdentity.js'; import { agentModelConfigurationReportKey } from '../../common/agentModelConfiguration.js'; import assert from 'assert'; @@ -16,7 +19,7 @@ import type { Database } from '@vscode/sqlite3'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { fileURLToPath } from 'url'; -import { DeferredPromise, disposableTimeout, raceTimeout, retry, timeout } from '../../../../base/common/async.js'; +import { DeferredPromise, disposableTimeout, raceTimeout, retry, Sequencer, timeout } from '../../../../base/common/async.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { CancellationError } from '../../../../base/common/errors.js'; import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; @@ -70,7 +73,7 @@ import { buildSandboxConfigForSdk } from '../../node/copilot/sandboxConfigForSdk import { AgentMergeConfigKey, readAgentMergeSessionState } from '../../common/agentMerge.js'; import { SessionDatabase, sessionDatabaseMigrations } from '../../node/sessionDatabase.js'; import { ActionType, ActionEnvelope, NotificationType, type ChatAction, type ChatConfigChangedAction, type INotification, type SessionAction, type SessionSummaryChanges } from '../../common/state/sessionActions.js'; -import { AH_META_AUTO_ARCHIVED_AT_DB_KEY, AH_META_CREATED_BY_SESSION_DB_KEY, AH_META_DEFAULT_CHAT_IS_READ_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, readSessionEhcliAdopted, AH_META_IS_ARCHIVED_DB_KEY, AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SESSION_META_EHCLI_ADOPTABLE_KEY, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_MULTI_ROOT_KEY, SessionLifecycle, SessionSourceControlOutcome, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, createErrorResponsePart, customizationId, isMessageRequestHiddenFromTranscript, isSessionStatusArchived, isSessionStatusRead, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionCreationReference, readSessionEhcliAdoptable, readSessionExternal, readSessionGitHubData, readSessionGitHubState, readSessionGitState, readWorkingDirectoryKeys, readWorkingDirectoryScopeIds, SESSION_META_GITHUB_DATA_KEY, readSessionMultiRootMetadata, readSessionFolderPickerDecision, readSessionSourceControlState, readSessionWorkspaceless, withSessionEhcliAdoptable, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionWorkspaceless, withWorkingDirectoryKey, withWorkingDirectoryScopeId, ChatOriginKind, type ChangesetState, type ISessionFolderPickerDecision, type ISessionWithDefaultChat, type MarkdownResponsePart, type SessionState, type SessionSummary, type SessionSummaryMeta, type ChatState, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js'; +import { AH_META_AUTO_ARCHIVED_AT_DB_KEY, AH_META_CREATED_BY_SESSION_DB_KEY, AH_META_DEFAULT_CHAT_IS_READ_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, readSessionEhcliAdopted, AH_META_IS_ARCHIVED_DB_KEY, AH_META_WORKSPACE_CONVERSION_QUARANTINED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SESSION_META_EHCLI_ADOPTABLE_KEY, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_MULTI_ROOT_KEY, SessionLifecycle, SessionSourceControlOutcome, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, createErrorResponsePart, customizationId, isMessageRequestHiddenFromTranscript, getAgentHostChatId, isSessionStatusArchived, isSessionStatusRead, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionCreationReference, readSessionEhcliAdoptable, readSessionExternal, readSessionGitHubData, readSessionGitHubState, readSessionGitState, readWorkingDirectoryKeys, readWorkingDirectoryScopeIds, SESSION_META_GITHUB_DATA_KEY, readSessionMultiRootMetadata, readSessionFolderPickerDecision, readSessionSourceControlState, readSessionWorkspaceless, withSessionEhcliAdoptable, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionWorkspaceless, withWorkingDirectoryKey, withWorkingDirectoryScopeId, ChatOriginKind, type ChangesetState, type ISessionFolderPickerDecision, type ISessionWithDefaultChat, type MarkdownResponsePart, type SessionState, type SessionSummary, type SessionSummaryMeta, type ChatState, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js'; import { BackgroundWorkKind, ChatInteractivity, PendingMessageKind, type BackgroundWork, type Message, type MessageAttachment, TerminalClaimKind, type TerminalClaim } from '../../common/state/protocol/state.js'; import { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../../common/meta/agentSnapshotAttachmentMeta.js'; import { readAgentMessageDelegationMeta } from '../../common/meta/agentMessageDelegationMeta.js'; @@ -10143,17 +10146,21 @@ suite('AgentService (node dispatcher)', () => { const trustedRootConfig = { [AgentHostWorkspaceTrustConfigKey]: { enabled: false, trustedUris: [] } }; const trigger = `config.${AgentHostPrewarmSessionsSettingId}`; - function createPrewarmService(telemetry: TestExperimentTriggerTelemetryService, gitService = createNoopGitService(), options?: { readonly multiRoot?: boolean }) { + function createPrewarmService(telemetry: TestExperimentTriggerTelemetryService, gitService = createNoopGitService(), options?: { readonly multiRoot?: boolean; readonly beforeCreateChat?: () => Promise }) { const svc = disposables.add(createTestAgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService, undefined, telemetry)); const warmed: string[] = []; const onDidWarm = disposables.add(new Emitter()); + const beforeCreateChat = options?.beforeCreateChat; class WarmableAgent extends MockAgent { override getDescriptor() { const base = super.getDescriptor(); return options?.multiRoot ? { ...base, capabilities: { ...base.capabilities, multipleWorkingDirectories: { immutablePrimary: true } } } : base; } override readonly chats: IAgentChats = withChatOverrides(getChatSurface(this), base => ({ - createChat: async (chat, context, options) => ({ ...await expectCreatedChat(base.createChat(chat, context, options)), provisional: true }), + createChat: async (chat, context, options) => { + await beforeCreateChat?.(); + return { ...await expectCreatedChat(base.createChat(chat, context, options)), provisional: true }; + }, warmChat: async chat => { warmed.push(chat.toString()); onDidWarm.fire(); @@ -10164,7 +10171,7 @@ suite('AgentService (node dispatcher)', () => { const agent = new WarmableAgent('copilotcli'); disposables.add(toDisposable(() => agent.dispose())); registerTestAgentProvider(svc, agent); - return { svc, warmed, onDidWarm: onDidWarm.event }; + return { svc, agent, warmed, onDidWarm: onDidWarm.event }; } test('warms a trusted folder draft only in the treatment arm, and reports the trigger in both arms', async () => { @@ -10223,6 +10230,38 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ warmed, triggers: telemetry.triggers }, { warmed: [], triggers: [] }); }); + test('a draft disposed while it is still being created is never warmed, and its provisional chat is disposed', async () => { + const telemetry = new TestExperimentTriggerTelemetryService(); + const createStarted = new DeferredPromise(); + const releaseCreate = new DeferredPromise(); + const { svc, agent, warmed } = createPrewarmService(telemetry, undefined, { + beforeCreateChat: () => { + createStarted.complete(); + return releaseCreate.p; + }, + }); + getConfigurationService(svc).updateRootConfig({ ...trustedRootConfig, [AgentHostPrewarmSessionsConfigKey]: true, [CopilotCliVSCodeAssignmentContextKey]: 'assignment-context' }); + const session = AgentSession.uri('copilotcli', 'disposed-while-creating'); + const created = svc.createSession({ legacyChatUris: true, provider: 'copilotcli', session, workingDirectories: [workingDirectory], activeClient }); + await createStarted.p; + const disposed = svc.disposeSession(session); + releaseCreate.complete(); + await Promise.all([created, disposed]); + await timeout(10); + + assert.deepStrictEqual({ + warmed, + triggers: telemetry.triggers, + disposedProviderSessions: agent.disposeSessionCalls.map(uri => uri.toString()), + sessions: getStateManager(svc).getSessionUris(), + }, { + warmed: [], + triggers: [], + disposedProviderSessions: [session.toString()], + sessions: [], + }); + }); + test('does not warm an untrusted folder or report its trigger', async () => { const telemetry = new TestExperimentTriggerTelemetryService(); const { svc, warmed } = createPrewarmService(telemetry); @@ -10286,28 +10325,56 @@ suite('AgentService (node dispatcher)', () => { }); }); - test('does not warm a worktree draft whose worktree is pending, or report its trigger', async () => { - const telemetry = new TestExperimentTriggerTelemetryService(); + function createRepositoryPrewarmService(telemetry: TestExperimentTriggerTelemetryService) { const gitService = createNoopGitService(); gitService.getRepositoryRoot = async () => workingDirectory; gitService.revParse = async () => 'head'; gitService.getHEAD = async () => ({ ref: 'refs/heads/feature', name: 'feature', kind: GitRefType.Head }); gitService.getDefaultBranch2 = async () => ({ ref: 'refs/heads/main', name: 'main', kind: GitRefType.Head }); - const { svc, warmed } = createPrewarmService(telemetry, gitService); - setTestAgentHostWorktreeIsolation(svc, disposables.add(new WorktreeIsolation( + const prewarm = createPrewarmService(telemetry, gitService); + setTestAgentHostWorktreeIsolation(prewarm.svc, disposables.add(new WorktreeIsolation( { _serviceBrand: undefined, generateBranchName: async () => 'agents/test' }, gitService, nullPullRequestResolver, nullSessionDataService, - getTestSessionStateStore(svc), + getTestSessionStateStore(prewarm.svc), new NullLogService(), ))); - getConfigurationService(svc).updateRootConfig({ ...trustedRootConfig, [AgentHostPrewarmSessionsConfigKey]: true, [CopilotCliVSCodeAssignmentContextKey]: 'assignment-context' }); + getConfigurationService(prewarm.svc).updateRootConfig({ ...trustedRootConfig, [AgentHostPrewarmSessionsConfigKey]: true, [CopilotCliVSCodeAssignmentContextKey]: 'assignment-context' }); + return prewarm; + } + + test('does not warm a worktree draft whose worktree is pending, or report its trigger', async () => { + const telemetry = new TestExperimentTriggerTelemetryService(); + const { svc, warmed } = createRepositoryPrewarmService(telemetry); const session = await svc.createSession({ legacyChatUris: true, provider: 'copilotcli', workingDirectories: [workingDirectory], activeClient, config: { [SessionConfigKey.Isolation]: 'worktree', [SessionConfigKey.Branch]: 'main' } }); await timeout(10); assert.deepStrictEqual({ warmed, triggers: telemetry.triggers, sessions: getStateManager(svc).getSessionUris() }, { warmed: [], triggers: [], sessions: [session.toString()] }); }); + + test('warms a folder draft in a repository, and a worktree picked at Send still defers to a first-send worktree', async () => { + // A client that keeps isolation local until Send creates the draft in + // the picked folder and sends the picked isolation with the first request. + const telemetry = new TestExperimentTriggerTelemetryService(); + const { svc, warmed, onDidWarm } = createRepositoryPrewarmService(telemetry); + const warm = Event.toPromise(onDidWarm); + const session = await svc.createSession({ legacyChatUris: true, provider: 'copilotcli', workingDirectories: [workingDirectory], activeClient, config: { [SessionConfigKey.Isolation]: 'folder' } }); + await warm; + const afterCreate = isWorkingDirectoryPending(svc, session); + svc.dispatchAction(session.toString(), { type: ActionType.SessionConfigChanged, config: { [SessionConfigKey.Isolation]: 'worktree', [SessionConfigKey.Branch]: 'main' } }, 'client-1', 1); + const afterWorktreePick = isWorkingDirectoryPending(svc, session); + svc.dispatchAction(session.toString(), { type: ActionType.SessionConfigChanged, config: { [SessionConfigKey.Isolation]: 'folder', [SessionConfigKey.Branch]: 'feature' } }, 'client-1', 2); + const afterFolderPick = isWorkingDirectoryPending(svc, session); + + assert.deepStrictEqual({ warmed, triggers: telemetry.triggers, afterCreate, afterWorktreePick, afterFolderPick }, { + warmed: [getStateManager(svc).getDefaultChat(session.toString())], + triggers: [trigger], + afterCreate: false, + afterWorktreePick: true, + afterFolderPick: false, + }); + }); }); suite('additional chat working directories', () => { @@ -23546,6 +23613,19 @@ suite('AgentService (node dispatcher)', () => { suite('authenticate', () => { + test('forwards optional authentication metadata to matching providers', async () => { + const metadata = { 'vscode.authentication.entitlement': { trackingId: 'analytics', sku: 'free' } }; + let received: Record | undefined; + const provider: IAgent = copilotAgent; + provider.authenticate = async (_resource, _token, _expiresIn, meta) => { + received = meta; + return true; + }; + registerTestAgentProvider(service, provider); + const result = await service.authenticate({ resource: 'https://api.github.com', token: 'token', _meta: metadata }); + assert.deepStrictEqual({ result, received }, { result: { authenticated: true }, received: metadata }); + }); + test('routes token to provider matching the resource', async () => { registerTestAgentProvider(service, copilotAgent); @@ -36586,6 +36666,334 @@ suite('AgentService (node dispatcher)', () => { }, { result: source.toString(), chats: [peer.toString(), buildDefaultChatUri(source)] }); }); + suite('move_session (Block 6)', () => { + /** The accessor the `move_session` tool runs moves through. */ + function moveToolAccessor(service: AgentService): IAgentServiceSessionServerToolAccessor { + return (service as unknown as { _createSessionServerToolAccessor(): IAgentServiceSessionServerToolAccessor })._createSessionServerToolAccessor(); + } + + async function refusalOf(check: Promise): Promise { + try { + await check; + return 'none'; + } catch (error) { + return error instanceof AgentChatMoveRefusedError ? error.reason : String(error); + } + } + + test('checks a move as the host would refuse it, changing nothing, and places a chat at the end of its destination', async () => { + const { service, source, target, peer } = await setupMove(); + const stateManager = getStateManager(service); + const second = URI.parse(buildChatUri(target, 'second')); + await service.createChat(target, second); + await timeout(0); + const accessor = moveToolAccessor(service); + const checks = { + move: await refusalOf(accessor.checkChatMove({ kind: 'chat', session: source, chat: peer, destination: { kind: 'session', session: target, after: 'end' } })), + reorder: await refusalOf(accessor.checkChatMove({ kind: 'chat', session: source, chat: peer, destination: { kind: 'session', session: source, after: URI.parse(buildDefaultChatUri(source)) } })), + mainSession: await refusalOf(accessor.checkChatMove({ kind: 'chat', session: source, chat: URI.parse(buildDefaultChatUri(source)), destination: { kind: 'newSession' } })), + afterItself: await refusalOf(accessor.checkChatMove({ kind: 'chat', session: source, chat: peer, destination: { kind: 'session', session: source, after: peer } })), + foreignAnchor: await refusalOf(accessor.checkChatMove({ kind: 'chat', session: source, chat: peer, destination: { kind: 'session', session: target, after: URI.parse(buildDefaultChatUri(source)) } })), + notStarted: await refusalOf(accessor.checkChatMove({ kind: 'chat', session: source, chat: peer, destination: { kind: 'newSession' } })), + notSingleChat: await refusalOf(accessor.checkChatMove({ kind: 'session', session: target, destination: source, after: 'end' })), + }; + const checkedChats = [source, target].map(session => stateManager.getSessionState(session.toString())?.chats.map(chat => chat.resource)); + + const moved = await accessor.applyChatMove({ kind: 'chat', session: source, chat: peer, destination: { kind: 'session', session: target, after: 'end' } }); + + assert.deepStrictEqual({ + checks, + checkedChats, + moved: moved.toString(), + targetChats: stateManager.getSessionState(target.toString())?.chats.map(chat => chat.resource), + }, { + checks: { + move: 'none', + reorder: 'none', + mainSession: AgentChatMoveRefusal.NotMovable, + afterItself: AgentChatMoveRefusal.InvalidAnchor, + foreignAnchor: AgentChatMoveRefusal.InvalidAnchor, + notStarted: AgentChatMoveRefusal.NotStarted, + notSingleChat: AgentChatMoveRefusal.NotSingleChat, + }, + checkedChats: [[buildDefaultChatUri(source), peer.toString()], [buildDefaultChatUri(target), second.toString()]], + moved: target.toString(), + targetChats: [buildDefaultChatUri(target), second.toString(), peer.toString()], + }); + }); + + test('a move of the calling chat is checked like the host move: an archived destination and a chat without a conversation of its own are refused (M1)', async () => { + const { service, source, target, peer } = await setupMove(); + const stateManager = getStateManager(service); + await service.restoreSession(target); + stateManager.dispatchServerAction(target.toString(), { type: ActionType.SessionIsArchivedChanged, isArchived: true }); + const archived = { + check: await refusalOf(moveToolAccessor(service).checkChatMove({ kind: 'chat', session: source, chat: peer, destination: { kind: 'session', session: target, after: 'end' } })), + host: await refusalOf(service.moveChat(source, peer, { kind: 'session', session: target, after: undefined })), + }; + + const unbacked = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + registerTestAgentProvider(unbacked, disposables.add(new class extends MovingAgent { + override async createChat(_session: URI, _chat: URI): Promise<{ providerData?: string }> { + return {}; + } + }('copilot'))); + getConfigurationService(unbacked).updateRootConfig({ [ChatMoveConfigKey.Enabled]: true }); + const unbackedSource = await unbacked.createSession({ legacyChatUris: true, provider: 'copilot', workingDirectories: [URI.file('/repo')] }); + const unbackedPeer = URI.parse(buildChatUri(unbackedSource, 'unbacked')); + await unbacked.createChat(unbackedSource, unbackedPeer); + await timeout(0); + const unsupported = { + check: await refusalOf(moveToolAccessor(unbacked).checkChatMove({ kind: 'chat', session: unbackedSource, chat: unbackedPeer, destination: { kind: 'newSession' } })), + host: await refusalOf(unbacked.moveChat(unbackedSource, unbackedPeer, { kind: 'newSession' })), + }; + + assert.deepStrictEqual({ archived, unsupported, owner: stateManager.getChatOwner(peer.toString()) }, { + archived: { check: AgentChatMoveRefusal.SessionArchived, host: AgentChatMoveRefusal.SessionArchived }, + unsupported: { check: AgentChatMoveRefusal.Unsupported, host: AgentChatMoveRefusal.Unsupported }, + owner: source.toString(), + }); + }); + + test('a promoted single-chat session moves as a whole session into another session (F2)', async () => { + const { service, source, target, peer } = await setupMove(); + const stateManager = getStateManager(service); + stateManager.dispatchServerAction(peer.toString(), { + type: ActionType.ChatTurnStarted, turnId: 'first-turn', startedAt: new Date(0).toISOString(), + message: { text: 'Hello', origin: { kind: MessageKind.User } }, + }); + stateManager.dispatchServerAction(peer.toString(), { type: ActionType.ChatTurnComplete, turnId: 'first-turn', duration: 1 }); + const promoted = await service.moveChat(source, peer, { kind: 'newSession' }); + const accessor = moveToolAccessor(service); + const ownership = getTestAgentServiceComposition(service).chatOwnership; + const resolve = async (link: string) => { + try { + const args = await getMoveSessionArgs({ session: link, into: buildOpenSessionLinkUri(target) }, createMoveSessionLookup(() => accessor.listSessions()), undefined, chat => ownership.getChatOwner(chat), session => ownership.getDefaultChat(session)); + return args.move.kind; + } catch (error) { + return String(error); + } + }; + const resolved = { session: await resolve(buildOpenSessionLinkUri(promoted)), chat: await resolve(buildOpenSessionLinkUri(promoted, getAgentHostChatId(peer))) }; + const move = { kind: 'session' as const, session: promoted, destination: target, after: 'end' as const }; + const check = await refusalOf(accessor.checkChatMove(move)); + let moved: string; + try { + moved = (await accessor.applyChatMove(move)).toString(); + } catch (error) { + moved = error instanceof AgentChatMoveRefusedError ? `${error.reason}: ${error.message}` : String(error); + } + assert.deepStrictEqual({ resolved, check, moved, owner: stateManager.getChatOwner(peer.toString()), promotedListed: stateManager.getSessionUris().includes(promoted.toString()) }, { + resolved: { session: 'session', chat: 'session' }, + check: 'none', + moved: target.toString(), + owner: target.toString(), + promotedListed: false, + }); + }); + + test('a promoted session not loaded since a restart moves as a whole session through the tool (F2)', async () => { + const orchestratorDatabase = new AgentHostDatabase(':memory:'); + const sessionDataService = createSessionDataService(new TestSessionDatabase()); + const createService = () => { + const created = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService(), + undefined, undefined, undefined, undefined, undefined, [], undefined, undefined, orchestratorDatabase)); + registerTestAgentProvider(created, disposables.add(new MovingAgent('copilot'))); + getConfigurationService(created).updateRootConfig({ [ChatMoveConfigKey.Enabled]: true }); + return created; + }; + const first = createService(); + const source = await first.createSession({ legacyChatUris: true, provider: 'copilot', workingDirectories: [URI.file('/repo')] }); + const peer = URI.parse(buildChatUri(source, 'promoted')); + await first.createChat(source, peer); + await timeout(0); + getStateManager(first).dispatchServerAction(peer.toString(), { + type: ActionType.ChatTurnStarted, turnId: 'first-turn', startedAt: new Date(0).toISOString(), + message: { text: 'Hello', origin: { kind: MessageKind.User } }, + }); + getStateManager(first).dispatchServerAction(peer.toString(), { type: ActionType.ChatTurnComplete, turnId: 'first-turn', duration: 1 }); + const promoted = await first.moveChat(source, peer, { kind: 'newSession' }); + await first.whenCatalogReconciliationIdle(); + await getTestSessionStateStore(first).whenIdle(undefined); + + const restarted = createService(); + await restarted.listSessions(); + // Created after the restart: the mock agent describes only the sessions it backs this run. + const target = await restarted.createSession({ legacyChatUris: true, provider: 'copilot', workingDirectories: [URI.file('/repo')] }); + await timeout(0); + const loadedBefore = getStateManager(restarted).getSessionUris().includes(promoted.toString()); + const accessor = moveToolAccessor(restarted); + const ownership = getTestAgentServiceComposition(restarted).chatOwnership; + const args = await getMoveSessionArgs({ session: buildOpenSessionLinkUri(promoted), into: buildOpenSessionLinkUri(target) }, createMoveSessionLookup(() => accessor.listSessions()), undefined, chat => ownership.getChatOwner(chat), session => ownership.getDefaultChat(session)); + const check = await refusalOf(accessor.checkChatMove(args.move)); + const moved = await accessor.applyChatMove(args.move); + + assert.deepStrictEqual({ loadedBefore, kind: args.move.kind, check, moved: moved.toString(), owner: getStateManager(restarted).getChatOwner(peer.toString()) }, { + loadedBefore: false, + kind: 'session', + check: 'none', + moved: target.toString(), + owner: target.toString(), + }); + }); + + test('the host links a move_session confirmation only to sessions and chats it knows, a single-chat session included', async () => { + const { service, source, target, peer } = await setupMove(); + const accessor = moveToolAccessor(service); + const host = (service as unknown as { readonly _serverToolHost: IAgentServerToolHost })._serverToolHost; + const unknown = URI.parse('copilot:/unknown'); + const message = (args: unknown) => { + const confirmation = host.getConfirmationDisplay(SessionServerToolName.MoveSession, args)?.confirmationMessage; + return typeof confirmation === 'string' ? confirmation : confirmation?.markdown; + }; + const sessionLink = (session: URI, chat?: URI) => buildOpenSessionLinkUri(session, chat && getAgentHostChatId(chat)); + assert.deepStrictEqual({ + known: { + // The target has no nested session: a single-chat session is known as a session. + singleChatSession: accessor.isKnownSessionLink(target, undefined), + session: accessor.isKnownSessionLink(source, undefined), + chat: accessor.isKnownSessionLink(source, getAgentHostChatId(peer)), + chatOfAnotherSession: accessor.isKnownSessionLink(target, getAgentHostChatId(peer)), + unknownChat: accessor.isKnownSessionLink(source, 'gone'), + unknownSession: accessor.isKnownSessionLink(unknown, undefined), + }, + messages: { + known: message({ session: sessionLink(source, peer), into: sessionLink(target) }), + unknown: message({ session: sessionLink(source, URI.parse(buildChatUri(source, 'gone'))), into: sessionLink(unknown) }), + chatUri: message({ session: peer.toString(), into: sessionLink(target) }), + notALink: message({ session: 'not a link', into: 'not a session' }), + }, + }, { + known: { singleChatSession: true, session: true, chat: true, chatOfAnotherSession: false, unknownChat: false, unknownSession: false }, + messages: { + known: `Move [this nested session](<${sessionLink(source, peer)}>) into [the destination session](<${sessionLink(target)}>)? It keeps its history and folders.`, + unknown: 'Move this nested session into the destination session? It keeps its history and folders.', + chatUri: `Move this session into [the destination session](<${sessionLink(target)}>) as a nested session? It keeps its history and folders.`, + notALink: 'Move this session into the destination session as a nested session? It keeps its history and folders.', + }, + }); + }); + + test('the move_session check and its move run in the move sequencer, after a move in progress, never beside it', async () => { + const { service, source, target, peer } = await setupMove(); + const sequencer = (service as unknown as { readonly _chatMoveSequencer: Sequencer })._chatMoveSequencer; + const running = new DeferredPromise(); + const order: string[] = []; + const inProgress = sequencer.queue(async () => { await running.p; order.push('move in progress'); }); + const accessor = moveToolAccessor(service); + const check = accessor.checkChatMove({ kind: 'chat', session: source, chat: peer, destination: { kind: 'session', session: target, after: 'end' } }).then(() => { order.push('check'); }); + const apply = accessor.applyChatMove({ kind: 'chat', session: source, chat: peer, destination: { kind: 'session', session: target, after: 'end' } }).then(() => { order.push('move_session'); }); + await timeout(10); + const whileRunning = [...order]; + running.complete(); + await Promise.all([inProgress, check, apply]); + assert.deepStrictEqual({ whileRunning, order, owner: getStateManager(service).getChatOwner(peer.toString()) }, { + whileRunning: [], + order: ['move in progress', 'check', 'move_session'], + owner: target.toString(), + }); + }); + + test('a nested session listed after a restart is made its own session without its session being opened first', async () => { + const orchestratorDatabase = new AgentHostDatabase(':memory:'); + const sessionDataService = createSessionDataService(new TestSessionDatabase()); + const createService = () => { + const created = disposables.add(createTestAgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService(), + undefined, undefined, undefined, undefined, undefined, [], undefined, undefined, orchestratorDatabase)); + // Describes every chat after the restart too, with a started conversation, as a provider that persisted it does. + const agent = new class extends MovingAgent { + override async getChatMetadata(chat: URI): Promise { + return { chat, startTime: 1, modifiedTime: 1, workingDirectories: [URI.file('/repo')] }; + } + }('copilot'); + agent.sessionMessages = [{ type: 'message', session: URI.parse('copilot:/history'), role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] }]; + registerTestAgentProvider(created, disposables.add(agent)); + getConfigurationService(created).updateRootConfig({ [ChatMoveConfigKey.Enabled]: true }); + return created; + }; + const first = createService(); + const source = await first.createSession({ legacyChatUris: true, provider: 'copilot', workingDirectories: [URI.file('/repo')] }); + const peer = URI.parse(buildChatUri(source, 'nested')); + await first.createChat(source, peer); + await timeout(0); + getStateManager(first).dispatchServerAction(peer.toString(), { + type: ActionType.ChatTurnStarted, turnId: 'first-turn', startedAt: new Date(0).toISOString(), + message: { text: 'Hello', origin: { kind: MessageKind.User } }, + }); + getStateManager(first).dispatchServerAction(peer.toString(), { type: ActionType.ChatTurnComplete, turnId: 'first-turn', duration: 1 }); + await first.whenCatalogReconciliationIdle(); + await getTestSessionStateStore(first).whenIdle(undefined); + + const restarted = createService(); + await restarted.listSessions(); + const loadedBefore = getStateManager(restarted).getSessionUris().includes(source.toString()); + const accessor = moveToolAccessor(restarted); + const ownership = getTestAgentServiceComposition(restarted).chatOwnership; + const args = await getMoveSessionArgs({ session: buildOpenSessionLinkUri(source, getAgentHostChatId(peer)), relationship: 'independent' }, createMoveSessionLookup(() => accessor.listSessions()), undefined, chat => ownership.getChatOwner(chat), session => ownership.getDefaultChat(session)); + const check = await refusalOf(accessor.checkChatMove(args.move)); + const promoted = await accessor.applyChatMove(args.move); + + assert.deepStrictEqual({ loadedBefore, kind: args.move.kind, check, newSession: promoted.toString() !== source.toString(), owner: getStateManager(restarted).getChatOwner(peer.toString()) }, { + loadedBefore: false, + kind: 'chat', + check: 'none', + newSession: true, + owner: promoted.toString(), + }); + }); + + test('sessions advertise move_session only while moving chats is enabled, following the setting without reconnecting', async () => { + const { service, source } = await setupMove(false); + const stateManager = getStateManager(service); + const offered = () => stateManager.getSessionState(source.toString())?.serverTools?.some(tool => tool.name === SessionServerToolName.MoveSession) ?? false; + const before = offered(); + getConfigurationService(service).updateRootConfig({ [ChatMoveConfigKey.Enabled]: true }); + const enabled = offered(); + getConfigurationService(service).updateRootConfig({ [ChatMoveConfigKey.Enabled]: false }); + assert.deepStrictEqual({ before, enabled, disabled: offered() }, { before: false, enabled: true, disabled: false }); + }); + + test('moves another chat while its turn waits for a confirmation: the turn, its question and the session status go along (D4)', async () => { + const { service, agent, source, target, peer } = await setupMove(); + const stateManager = getStateManager(service); + const flags = (session: URI) => { + const status = stateManager.getSessionState(session.toString())?.status ?? 0; + return { inProgress: (status & SessionStatus.InProgress) !== 0, inputNeeded: (status & SessionStatus.InputNeeded) !== 0 }; + }; + stateManager.dispatchServerAction(peer.toString(), { + type: ActionType.ChatTurnStarted, turnId: 'running', startedAt: new Date(0).toISOString(), + message: { text: 'Working', origin: { kind: MessageKind.User } }, + }); + agent.fireProgress({ kind: 'action', resource: peer, action: { type: ActionType.ChatToolCallStart, turnId: 'running', toolCallId: 'tc-1', toolName: 'runTests', displayName: 'Run tests', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); + agent.fireProgress({ + kind: 'pending_confirmation', chat: peer, + state: { status: ToolCallStatus.PendingConfirmation, toolCallId: 'tc-1', toolName: 'runTests', displayName: 'Run tests', invocationMessage: 'Run tests', confirmationTitle: 'Allow tests?' }, + permissionKind: 'custom-tool', permissionPath: undefined, + }); + await timeout(0); + const beforeMove = { source: flags(source), target: flags(target) }; + + await moveToolAccessor(service).applyChatMove({ kind: 'chat', session: source, chat: peer, destination: { kind: 'session', session: target, after: 'end' } }); + const afterMove = { source: flags(source), target: flags(target), activeTurn: stateManager.getChatState(peer.toString())?.activeTurn?.id }; + + service.dispatchAction(peer.toString(), { type: ActionType.ChatToolCallConfirmed, turnId: 'running', toolCallId: 'tc-1', approved: true, confirmed: ToolCallConfirmationReason.UserAction }, 'client-1', 1); + agent.fireProgress({ kind: 'action', resource: peer, action: { type: ActionType.ChatTurnComplete, turnId: 'running', duration: 1 } }); + await timeout(0); + + assert.deepStrictEqual({ + beforeMove, + afterMove, + answered: agent.respondToPermissionCalls, + afterTurn: { target: flags(target), turns: stateManager.getChatState(peer.toString())?.turns.map(turn => turn.id) }, + }, { + beforeMove: { source: { inProgress: true, inputNeeded: true }, target: { inProgress: false, inputNeeded: false } }, + afterMove: { source: { inProgress: false, inputNeeded: false }, target: { inProgress: true, inputNeeded: true }, activeTurn: 'running' }, + answered: [{ requestId: 'tc-1', approved: true }], + afterTurn: { target: { inProgress: false, inputNeeded: false }, turns: ['running'] }, + }); + }); + }); + test('refuses moves it does not support and changes nothing', async () => { const disabled = await setupMove(false); const refusals: Record = {}; diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index b3e04acc0d8ca4..67367461bb9486 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -73,6 +73,7 @@ import { AgentHostDatabase } from '../../node/agentHostDatabase.js'; import { AgentSessionRegistry, IAgentSessionRegistry } from '../../node/agentSessionRegistry.js'; import { AdditionalWorktreeLifecycleService, IAdditionalWorktreeLifecycleService } from '../../node/chatContributions/additionalWorktreeLifecycle/additionalWorktreeLifecycleService.js'; import { ISessionWorkspaceConversionService } from '../../node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.js'; +import { IAgentHostScheduledChatMoves } from '../../node/chatContributions/scheduledChatMoves/scheduledChatMovesService.js'; import { IAgentHostArchivedChatWorkspaceService } from '../../node/chatContributions/chatArchive/archivedChatWorkspaceService.js'; import { AgentHostTelemetryReporter, IAgentHostTelemetryReporter, type IAgentHostAskQuestionsToolInvokedEvent, type IAgentHostTurnCompletedEvent } from '../../node/agentHostTelemetryReporter.js'; import { IAgentHostSessionPromptService } from '../../node/agentHostSessionPromptService.js'; @@ -279,6 +280,7 @@ function createTestSideEffects( cancel: () => { }, updateSessionWorkspace: async () => { }, }); + services.set(IAgentHostScheduledChatMoves, { _serviceBrand: undefined, schedule: () => { }, turnEnded: () => { } } satisfies IAgentHostScheduledChatMoves); const titleController = disposables.add(new AgentHostSessionTitleController(stateManager, { sessionDataService: options.sessionDataService, getInitialTitleGenerationStrategy: () => options.initialTitleGenerationStrategy ?? 'deferred', @@ -9905,10 +9907,18 @@ suite('AgentSideEffects', () => { await discarded.p; }); - test('ChatTruncated fires onSessionTruncated once', () => { + test('ChatTruncated refreshes changesets once after provider data is truncated', async () => { setupSession(); - const changesets = new FakeChangesetService(); + const truncation = new DeferredPromise(); + const refreshed = new DeferredPromise(); + agent.truncateChat = () => truncation.p; + const changesets = new class extends FakeChangesetService { + override onSessionTruncated(session: string): void { + super.onSessionTruncated(session); + refreshed.complete(); + } + }; const localSideEffects = createTestSideEffects(disposables, stateManager, { getAgent: () => agent, agents: agentList, @@ -9920,6 +9930,9 @@ suite('AgentSideEffects', () => { turnId: 'turn-1', }); + assert.deepStrictEqual(changesets.truncates, []); + truncation.complete(); + await refreshed.p; assert.deepStrictEqual(changesets.truncates, [sessionUri.toString()]); }); diff --git a/src/vs/platform/agentHost/test/node/chatContributions.test.ts b/src/vs/platform/agentHost/test/node/chatContributions.test.ts index c9b53db76fbcfa..243d3c57cc0eb7 100644 --- a/src/vs/platform/agentHost/test/node/chatContributions.test.ts +++ b/src/vs/platform/agentHost/test/node/chatContributions.test.ts @@ -70,6 +70,7 @@ import { LocalCommandContribution } from '../../node/chatContributions/localComm import { MarkUnreadContribution } from '../../node/chatContributions/markUnread/markUnreadContribution.js'; import { QueueDrainContribution } from '../../node/chatContributions/queueDrain/queueDrainContribution.js'; import { ISessionWorkspaceConversionService } from '../../node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionService.js'; +import { IAgentHostScheduledChatMoves } from '../../node/chatContributions/scheduledChatMoves/scheduledChatMovesService.js'; import { IAgentHostArchivedChatWorkspaceService } from '../../node/chatContributions/chatArchive/archivedChatWorkspaceService.js'; import { SessionWorkspaceConversionContribution } from '../../node/chatContributions/sessionWorkspaceConversion/sessionWorkspaceConversionContribution.js'; import { SessionTitleContribution } from '../../node/chatContributions/sessionTitle/sessionTitleContribution.js'; @@ -977,6 +978,7 @@ function createBuiltInContributions(disposables: ReturnType { }, updateSessionWorkspace: async () => { observed?.push('sessionWorkspaceConversion'); }, }); + services.set(IAgentHostScheduledChatMoves, { _serviceBrand: undefined, schedule: () => { }, turnEnded: () => { observed?.push('scheduledChatMoves'); } } satisfies IAgentHostScheduledChatMoves); services.set(IAgentHostSessionTitleController, new RecordingTitleController(observed, enableSendInstructions ? 'rename instruction' : undefined)); const queueAgent = new MockAgent(); const providerService = createTestAgentHostProviderService(() => queueAgent); @@ -1069,6 +1071,7 @@ function createQueueDrainContributions(disposables: ReturnType { }, }); + services.set(IAgentHostScheduledChatMoves, { _serviceBrand: undefined, schedule: () => { }, turnEnded: () => { } } satisfies IAgentHostScheduledChatMoves); const mockAgent = new MockAgent(); let agent: MockAgent | undefined = mockAgent; const pendingMessages: (PendingMessage | undefined)[] = []; @@ -2150,7 +2153,7 @@ suite('AgentHostChatContributions', () => { const contributions = createBuiltInContributions(disposables, observed); contributions.service.turnEnd(turnEnd('built-in-order')); - assert.deepStrictEqual(observed, ['checkpointAndChangeset', 'sessionWorkspaceConversion', 'queueDrain', 'githubReferences', 'sessionTitle', 'markUnread']); + assert.deepStrictEqual(observed, ['checkpointAndChangeset', 'sessionWorkspaceConversion', 'scheduledChatMoves', 'queueDrain', 'githubReferences', 'sessionTitle', 'markUnread']); }); for (const source of ['direct', 'queued'] as const) { @@ -2188,6 +2191,19 @@ suite('AgentHostChatContributions', () => { assert.deepStrictEqual(observed, ['gitRepositoryRoots', 'checkpointAndChangeset']); }); + test('cancelled turns refresh tracked changesets without capturing a completed Git checkpoint', () => { + const observed: string[] = []; + const contributions = createBuiltInContributions(disposables, observed); + const calls: Array<{ channel: string; turnId: string | undefined }> = []; + contributions.changesets.onTurnComplete = (channel, turnId) => { calls.push({ channel, turnId }); }; + const turn = turnEnd('cancelled', { kind: 'cancelled' }); + contributions.service.turnEnd(turn); + assert.deepStrictEqual({ calls, checkpoint: observed.includes('checkpointAndChangeset') }, { + calls: [{ channel: turn.channel, turnId: turn.turnId }, { channel: turn.session, turnId: turn.turnId }], + checkpoint: false, + }); + }); + test('refreshes repository roots for completed and cancelled turns, not rejected or resumable requests', () => { const contributions = createBuiltInContributions(disposables); const refreshed: string[] = []; @@ -2436,12 +2452,14 @@ suite('AgentHostChatContributions', () => { githubReferences: observed.includes('githubReferences'), sessionTitle: observed.includes('sessionTitle'), markUnread: observed.includes('markUnread'), + scheduledChatMoves: observed.includes('scheduledChatMoves'), }, { checkpointAndChangeset: false, queueDrain: false, githubReferences: false, sessionTitle: false, markUnread: false, + scheduledChatMoves: false, }); }); diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index bfb252a8f03db0..b8154bd1d57dfd 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -9,6 +9,7 @@ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import type { CCAModel } from '@vscode/copilot-api'; import assert from 'assert'; +import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; import { mock } from '../../../../base/test/common/mock.js'; import { isCustomizationEnabled } from '../../common/customizationEnablement.js'; import * as fs from 'fs/promises'; @@ -61,6 +62,10 @@ import { buildNonPtyShellTerminalUri } from '../../common/nonPtyShellTerminalUri import { ISessionDataService } from '../../common/sessionDataService.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; import { ChatOriginKind, CustomizationEnablementKind, ProtectedResourceMetadata, ChatInputAnswerState, ChatInputAnswerValueKind, ToolCallStatus, type SessionConfigState, type ChatInputRequest, type ToolDefinition } from '../../common/state/protocol/state.js'; +import { SessionServerToolName } from '../../common/serverToolNames.js'; +import { serverToolAllowList } from '../../node/claude/claudeServerToolMcpServer.js'; +import { moveSessionToolDefinition } from '../../node/shared/sessionMoveTools.js'; +import { sessionToolRequiresConfirmation } from '../../node/shared/sessionServerTools.js'; import { IAgentHostGitService } from '../../common/agentHostGitService.js'; import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js'; import { IAgentServerToolHost } from '../../common/agentServerTools.js'; @@ -1898,7 +1903,7 @@ suite('ClaudeAgent', () => { return Response.json({ endpoints: { api: 'https://api.githubcopilot.com' }, access_type_sku: 'sku-a' }); } return Response.json({ data: ALL_MODELS }); - }, logService, FakeProductService, endpoints)); + }, logService, FakeProductService, endpoints, NullTelemetryService)); const proxy = disposables.add(new ClaudeProxyService(logService, api)); const { agent } = createTestContext(disposables, { gitHubEndpointService: endpoints, copilotApiService: api, claudeProxyService: proxy }); const authenticating = agent.authenticate(endpoints.getCopilotResource().resource, 'test-token-a'); @@ -7142,6 +7147,7 @@ suite('ClaudeAgent (Phase 7 §3.4 — _handleCanUseTool)', () => { getDefinitionsForSession(): readonly ToolDefinition[] { return this.definitions; } canRequireConfirmation(): boolean { return true; } requiresConfirmation(): boolean { return this.confirmationRequiredForSession; } + getConfirmationDisplay(): undefined { return undefined; } executeTool(): string { return 'ok'; } } @@ -7285,6 +7291,42 @@ suite('ClaudeAgent (Phase 7 §3.4 — _handleCanUseTool)', () => { }); }); + test('move_session is kept out of the auto-approve allow-list and asks through canUseTool', async () => { + // The production confirmation policy over the real move_session definition, next to a tool that never confirms. + const host = new class implements IAgentServerToolHost { + readonly definitions: readonly ToolDefinition[] = [ + { name: SessionServerToolName.ListSessions, description: 'List sessions', inputSchema: { type: 'object', properties: {} } }, + { name: moveSessionToolDefinition.name, description: moveSessionToolDefinition.description, inputSchema: moveSessionToolDefinition.inputSchema }, + ]; + readonly toolNames = this.definitions.map(definition => definition.name); + advertise(): void { } + getDefinitionsForSession(): readonly ToolDefinition[] { return this.definitions; } + canRequireConfirmation(toolName: string): boolean { return sessionToolRequiresConfirmation(toolName); } + requiresConfirmation(_chat: string, toolName: string): boolean { return sessionToolRequiresConfirmation(toolName); } + getConfirmationDisplay(): undefined { return undefined; } + executeTool(): string { return 'ok'; } + }(); + const { ctx, canUseTool } = await materialize(undefined, host); + const signals: AgentSignal[] = []; + disposables.add(ctx.agent.onDidChatProgress(signal => signals.push(signal))); + + const input = { session: 'agent-host-session://claude/s1?chat=peer', into: 'agent-host-session://claude/s2' }; + const resultPromise = canUseTool(serverToolAllowList([SessionServerToolName.MoveSession])[0], input, makeOptions('tu_move_session')); + await tick(); + ctx.agent.respondToPermissionRequest('tu_move_session', true); + const confirmation = signals.find(signal => signal.kind === 'pending_confirmation'); + + assert.deepStrictEqual({ + allowedTools: ctx.sdk.capturedStartupOptions[0]?.allowedTools, + result: await resultPromise, + confirmationTitle: confirmation?.kind === 'pending_confirmation' ? confirmation.state.confirmationTitle : undefined, + }, { + allowedTools: serverToolAllowList([SessionServerToolName.ListSessions]), + result: { behavior: 'allow', updatedInput: input }, + confirmationTitle: 'Move Nested Session?', + }); + }); + // Tests 3 and 4 (bypassPermissions / acceptEdits auto-allow) intentionally // omitted: the SDK auto-approves under those modes BEFORE invoking // `canUseTool`, so there is no host-side branch to exercise. See @@ -11404,6 +11446,7 @@ suite('ClaudeAgent — host seams', () => { getDefinitionsForSession: () => [{ name: toolName, inputSchema: { type: 'object', properties: {} } }], canRequireConfirmation: () => false, requiresConfirmation: () => false, + getConfirmationDisplay: () => undefined, executeTool: chatUri => { executedChatUri = chatUri; return 'done'; @@ -11443,6 +11486,7 @@ suite('ClaudeAgent — host seams', () => { }, canRequireConfirmation: () => false, requiresConfirmation: () => false, + getConfirmationDisplay: () => undefined, executeTool: () => 'done', }); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); @@ -11481,6 +11525,7 @@ suite('ClaudeAgent — host seams', () => { }, canRequireConfirmation: () => false, requiresConfirmation: () => false, + getConfirmationDisplay: () => undefined, executeTool: () => 'done', }); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); diff --git a/src/vs/platform/agentHost/test/node/claudeServerToolMcpServer.test.ts b/src/vs/platform/agentHost/test/node/claudeServerToolMcpServer.test.ts index 05ac8bdeb6c6c6..f0c35bfa8ff6fa 100644 --- a/src/vs/platform/agentHost/test/node/claudeServerToolMcpServer.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeServerToolMcpServer.test.ts @@ -66,6 +66,7 @@ class FakeServerToolHost implements IAgentServerToolHost { canRequireConfirmation(_toolName: string): boolean { return false; } requiresConfirmation(_sessionUri: string, _toolName: string): boolean { return false; } + getConfirmationDisplay(): undefined { return undefined; } executeTool(chatUri: string, toolName: string, rawArgs: unknown): string { this.executions.push({ chatUri, toolName, rawArgs }); 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 49ad939e9f32b4..3d43917dda00f3 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexCreateChat.test.ts @@ -391,6 +391,7 @@ function createRecordingServerToolHost(advertised: string[]): IAgentServerToolHo getDefinitionsForSession: () => [], canRequireConfirmation: () => false, requiresConfirmation: () => false, + getConfirmationDisplay: () => undefined, executeTool: () => '', }; } @@ -404,6 +405,7 @@ function createThrowingAdvertiseServerToolHost(message: string): IAgentServerToo getDefinitionsForSession: () => [], canRequireConfirmation: () => false, requiresConfirmation: () => false, + getConfirmationDisplay: () => undefined, executeTool: () => '', }; } @@ -425,6 +427,7 @@ function createRecordingChatServerToolHost(calls: { readonly method: 'requiresCo calls.push({ method: 'requiresConfirmation', chatUri: chatUri.toString() }); return false; }, + getConfirmationDisplay: () => undefined, executeTool: (chatUri, _toolName, _rawArgs) => { calls.push({ method: 'executeTool', chatUri: chatUri.toString() }); return 'tool result'; @@ -4201,7 +4204,13 @@ suite('CodexAgent chat backing durability', () => { const folder = URI.file('/repo/cancel-replace'); const threadId = 'cancel-replace-thread'; const signals: AgentSignal[] = []; - disposables.add(agent.onDidChatProgress(signal => signals.push(signal))); + const completed = new DeferredPromise(); + disposables.add(agent.onDidChatProgress(signal => { + signals.push(signal); + if (signal.kind === 'action' && signal.action.type === ActionType.ChatTurnComplete && signal.action.turnId === 'turn-2') { + completed.complete(); + } + })); await materializeSession(agent, peer, session, chat, folder, threadId); const appTurn = { id: 'app-turn-1', items: [], itemsView: 'notLoaded', status: 'inProgress', error: null, startedAt: 1, completedAt: null, durationMs: null }; @@ -4222,6 +4231,7 @@ suite('CodexAgent chat backing durability', () => { peer.push({ id: replacement.id, result: {} }); await sending; peer.push({ method: 'turn/completed', params: { threadId, turn: { ...appTurn, id: 'app-turn-2', status: 'completed', completedAt: 3, durationMs: 1000 } } }); + await completed.p; assert.deepStrictEqual(signals.flatMap(signal => signal.kind === 'action' && (signal.action.type === ActionType.ChatTurnCancelled || signal.action.type === ActionType.ChatTurnComplete) diff --git a/src/vs/platform/agentHost/test/node/codex/codexLaunchConfig.test.ts b/src/vs/platform/agentHost/test/node/codex/codexLaunchConfig.test.ts index af14d4cc6a49b1..ba442e4560fb47 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexLaunchConfig.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexLaunchConfig.test.ts @@ -17,6 +17,7 @@ suite('CodexLaunchConfig', () => { assert.ok(!config.args.some(argument => argument.startsWith('model_provider='))); assert.ok(config.args.includes('model_providers.vscode-proxy.requires_openai_auth=false')); assert.ok(config.args.includes('features.image_generation=false')); + assert.ok(config.args.includes('features.cwd_relative_turn_diffs=true')); assert.ok(config.args.includes('shell_environment_policy.set.AI_AGENT="github_copilot_vscode_agent"')); assert.ok(config.args.includes('--log-level=debug')); assert.ok(config.args.indexOf('analytics.enabled=true') < config.args.lastIndexOf('analytics.enabled=false')); 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 468de55cbab499..441c3162d2e379 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexPrewarmEviction.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexPrewarmEviction.test.ts @@ -4546,6 +4546,7 @@ suite('CodexAgent prewarm eviction', () => { getDefinitionsForSession: () => [tool], canRequireConfirmation: sessionToolRequiresConfirmation, requiresConfirmation: (_chatUri, toolName) => sessionToolRequiresConfirmation(toolName), + getConfirmationDisplay: () => undefined, executeTool: (chatUri, toolName) => { executions.push({ chatUri, toolName }); return 'Workspace change scheduled'; diff --git a/src/vs/platform/agentHost/test/node/codex/codexSkuTelemetry.test.ts b/src/vs/platform/agentHost/test/node/codex/codexSkuTelemetry.test.ts index 3bde63274d121d..3a64f96e23529b 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexSkuTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexSkuTelemetry.test.ts @@ -19,6 +19,7 @@ import product from '../../../../product/common/product.js'; import { IProductService } from '../../../../product/common/productService.js'; import { ITelemetryService, TelemetryLevel, type ITelemetryData } from '../../../../telemetry/common/telemetry.js'; import { TelemetryService } from '../../../../telemetry/common/telemetryService.js'; +import { NullTelemetryService } from '../../../../telemetry/common/telemetryUtils.js'; import { AgentSession } from '../../../common/agent.js'; import { AgentHostConfigKey } from '../../../common/agentHostCustomizationConfig.js'; import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../../../common/agentHostCheckpointService.js'; @@ -56,7 +57,7 @@ function createHarness(disposables: Pick, fetch: FetchFu const configurationService = disposables.add(new AgentConfigurationService(stateManager, logService)); const endpoints = disposables.add(new AgentHostGitHubEndpointService(configurationService, logService)); const productService: IProductService = { _serviceBrand: undefined, ...product }; - const apiService = disposables.add(new CopilotApiService(fetch, logService, productService, endpoints)); + const apiService = disposables.add(new CopilotApiService(fetch, logService, productService, endpoints, NullTelemetryService)); const events: { eventName: string; data: ITelemetryData }[] = []; const telemetry = disposables.add(new AgentHostTelemetryService(TelemetryService.createWithLevel({ telemetryLevel: TelemetryLevel.USAGE, diff --git a/src/vs/platform/agentHost/test/node/codex/codexTurnDiff.test.ts b/src/vs/platform/agentHost/test/node/codex/codexTurnDiff.test.ts new file mode 100644 index 00000000000000..adf64123d9befe --- /dev/null +++ b/src/vs/platform/agentHost/test/node/codex/codexTurnDiff.test.ts @@ -0,0 +1,373 @@ +/*--------------------------------------------------------------------------------------------- + * 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 } from 'crypto'; +import { DeferredPromise } from '../../../../../base/common/async.js'; +import { VSBuffer } from '../../../../../base/common/buffer.js'; +import { join } from '../../../../../base/common/path.js'; +import { isWindows } from '../../../../../base/common/platform.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { FileService } from '../../../../files/common/fileService.js'; +import { InMemoryFileSystemProvider } from '../../../../files/common/inMemoryFilesystemProvider.js'; +import { NullLogService } from '../../../../log/common/log.js'; +import { FileEditKind } from '../../../common/state/sessionState.js'; +import { storeCodexTurnDiffs } from '../../../node/codex/codexTurnDiff.js'; +import { CodexTurnDiffTracker } from '../../../node/codex/codexTurnDiffTracker.js'; +import { SessionDatabase } from '../../../node/sessionDatabase.js'; + +suite('Codex turn diff snapshots', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + const cwd = URI.file(isWindows ? 'c:\\workspace' : '/workspace'); + const expectedPath = (name: string) => URI.joinPath(cwd, name).path; + let database: SessionDatabase; + let fileService: FileService; + const logService = new NullLogService(); + + setup(async () => { + database = disposables.add(await SessionDatabase.open(':memory:')); + fileService = disposables.add(new FileService(logService)); + const provider = disposables.add(new InMemoryFileSystemProvider()); + if (isWindows) { + // FileService assumes drive roots exist; seed this one using provider-native POSIX paths. + await provider.mkdir(URI.from({ scheme: 'inmemory', path: '/c:' })); + } + disposables.add(fileService.registerProvider('file', provider)); + }); + + function diff(before: string | undefined, after: string | undefined, hunks: string, beforePath = 'edit.txt', afterPath = beforePath): string { + const hash = (content: string | undefined) => content === undefined ? '0'.repeat(40) + : createHash('sha1').update(`blob ${Buffer.byteLength(content)}\0`).update(content).digest('hex'); + return `diff --git a/${beforePath} b/${afterPath}\n` + + (before === undefined ? 'new file mode 100644\n' : after === undefined ? 'deleted file mode 100644\n' : '') + + `index ${hash(before)}..${hash(after)}\n` + + (hunks ? `--- ${before === undefined ? '/dev/null' : `a/${beforePath}`}\n+++ ${after === undefined ? '/dev/null' : `b/${afterPath}`}\n${hunks}` : ''); + } + + function fileSequences(names: readonly string[], first: number, last = first): Map { + return new Map(names.map(name => [join(cwd.fsPath, name), { first, last }])); + } + + function fixturePaths(patch: string): string[] { + return [...new Set([...patch.matchAll(/^diff --git a\/(.+) b\/(.+)$/gm)].flatMap(match => [match[1], match[2]]))]; + } + + async function capture(patch: string, boundaries = [{ turnId: 'host-turn', diff: patch }]): Promise { + for (const boundary of boundaries) { + await database.setTurnEventId(boundary.turnId, 'native-turn'); + } + await storeCodexTurnDiffs([{ + notification: { threadId: 'thread', turnId: 'native-turn', diff: patch }, + initialDiffs: boundaries.map(boundary => boundary.diff), + snapshots: boundaries.map((boundary, index) => ({ + ...boundary, startSequence: index + 1, sequence: index + 1, + fileSequences: fileSequences(fixturePaths(boundaries.map(item => item.diff).join('')), 1, index + 1), + })), + }], { + relatedDiffs: [], cwd, nativeCwd: cwd, database, fileService, logService, + }); + } + + async function write(content: string, path = 'edit.txt'): Promise { + await fileService.writeFile(URI.file(join(cwd.fsPath, path)), VSBuffer.fromString(content)); + } + + async function snapshots(turnId = 'host-turn') { + const records = await database.getFileEditsByTurn(turnId); + return Promise.all(records.map(async record => { + const content = await database.readFileEditContent(record.toolCallId, record.filePath); + return { + path: URI.file(record.filePath).path, kind: record.kind, + before: content?.beforeContent && VSBuffer.wrap(content.beforeContent).toString(), + after: content?.afterContent && VSBuffer.wrap(content.afterContent).toString(), + }; + })); + } + + test('reconstructs unchanged text outside multiple hunks', async () => { + const before = 'prefix0\nkeep1\nold2\nkeep3\nkeep4\nkeep5\nkeep6\nkeep7\nkeep8\nold9\nsuffix10\n'; + const after = before.replace('old2', 'new2').replace('old9', 'new9'); + await write(after); + await capture(diff(before, after, '@@ -2,3 +2,3 @@\n keep1\n-old2\n+new2\n keep3\n@@ -9,3 +9,3 @@\n keep8\n-old9\n+new9\n suffix10\n')); + assert.deepStrictEqual(await snapshots(), [{ path: expectedPath('edit.txt'), kind: FileEditKind.Edit, before, after }]); + }); + + test('preserves CRLF, a byte order mark, and a missing final newline', async () => { + const before = '\uFEFFone\r\nold\r\nend'; + const after = '\uFEFFone\r\nnew\r\nend'; + await write(after, 'space name.txt'); + await capture(diff(before, after, '@@ -1,3 +1,3 @@\n \uFEFFone\r\n-old\r\n+new\r\n end\n\\ No newline at end of file\n', 'space name.txt')); + assert.deepStrictEqual(await snapshots(), [{ path: expectedPath('space name.txt'), kind: FileEditKind.Edit, before, after }]); + }); + + test('preserves differing missing-newline markers on both sides', async () => { + await write('new'); + await capture(diff('old', 'new', '@@ -1 +1 @@\n-old\n\\ No newline at end of file\n+new\n\\ No newline at end of file\n')); + assert.deepStrictEqual(await snapshots(), [{ path: expectedPath('edit.txt'), kind: FileEditKind.Edit, before: 'old', after: 'new' }]); + }); + + test('retains deleted contents without reading the missing file', async () => { + await capture(diff('deleted\n', undefined, '@@ -1 +0,0 @@\n-deleted\n')); + assert.deepStrictEqual(await snapshots(), [{ path: expectedPath('edit.txt'), kind: FileEditKind.Delete, before: 'deleted\n', after: undefined }]); + }); + + test('retains created contents without inventing a before side', async () => { + await write('created\n'); + await capture(diff(undefined, 'created\n', '@@ -0,0 +1 @@\n+created\n')); + assert.deepStrictEqual(await snapshots(), [{ path: expectedPath('edit.txt'), kind: FileEditKind.Create, before: undefined, after: 'created\n' }]); + }); + + test('records a move by its two physical paths', async () => { + await write('pear\n', 'destination.txt'); + await capture(diff('apple\n', 'pear\n', '@@ -1 +1 @@\n-apple\n+pear\n', 'source.txt', 'destination.txt')); + assert.deepStrictEqual(await snapshots(), [ + { path: expectedPath('source.txt'), kind: FileEditKind.Delete, before: 'apple\n', after: undefined }, + { path: expectedPath('destination.txt'), kind: FileEditKind.Create, before: undefined, after: 'pear\n' }, + ]); + }); + + test('does not include edits made outside the reported patch', async () => { + await write('after\nunrelated user edit\n'); + await capture(diff('before\n', 'after\n', '@@ -1 +1 @@\n-before\n+after\n')); + assert.deepStrictEqual(await snapshots(), []); + }); + + test('does not fabricate before contents from invalid hunks', async () => { + await write('after\n'); + await capture(diff('actual before\n', 'after\n', '@@ -1 +1 @@\n-invented before\n+after\n')); + assert.deepStrictEqual(await snapshots(), []); + }); + + test('supports empty-file creates and deletes without hunk headers', async () => { + await write('', 'created.txt'); + await capture(diff(undefined, '', '', 'created.txt') + diff('', undefined, '', 'deleted.txt')); + assert.deepStrictEqual(await snapshots(), [ + { path: expectedPath('created.txt'), kind: FileEditKind.Create, before: undefined, after: '' }, + { path: expectedPath('deleted.txt'), kind: FileEditKind.Delete, before: '', after: undefined }, + ]); + }); + + test('handles a large unchanged suffix without exceeding the argument limit', async () => { + const suffix = 'unchanged\n'.repeat(150_000); + await write('after\n' + suffix); + await capture(diff('before\n' + suffix, 'after\n' + suffix, '@@ -1 +1 @@\n-before\n+after\n')); + const result = await snapshots(); + assert.deepStrictEqual(result.map(({ before, after }) => ({ before, after })), [{ before: 'before\n' + suffix, after: 'after\n' + suffix }]); + }); + + test('splits cumulative native diffs into consecutive host checkpoints', async () => { + await write('c\n'); + const first = diff('a\n', 'b\n', '@@ -1 +1 @@\n-a\n+b\n'); + const final = diff('a\n', 'c\n', '@@ -1 +1 @@\n-a\n+c\n'); + await capture(final, [{ turnId: 'host-turn', diff: first }, { turnId: 'steering-turn', diff: final }]); + assert.deepStrictEqual([await snapshots(), await snapshots('steering-turn')], [ + [{ path: expectedPath('edit.txt'), kind: FileEditKind.Edit, before: 'a\n', after: 'b\n' }], + [{ path: expectedPath('edit.txt'), kind: FileEditKind.Edit, before: 'b\n', after: 'c\n' }], + ]); + }); + + test('retains both checkpoints when a steered edit reverts the native diff to empty', async () => { + await write('a\n'); + await capture('', [ + { turnId: 'host-turn', diff: diff('a\n', 'b\n', '@@ -1 +1 @@\n-a\n+b\n') }, + { turnId: 'steering-turn', diff: '' }, + ]); + assert.deepStrictEqual([await snapshots(), await snapshots('steering-turn')], [ + [{ path: expectedPath('edit.txt'), kind: FileEditKind.Edit, before: 'a\n', after: 'b\n' }], + [{ path: expectedPath('edit.txt'), kind: FileEditKind.Edit, before: 'b\n', after: 'a\n' }], + ]); + }); + + test('retains transient creates and deletes across host checkpoints', async () => { + await write('restored\n', 'deleted.txt'); + await capture('', [ + { + turnId: 'host-turn', diff: diff(undefined, 'created\n', '@@ -0,0 +1 @@\n+created\n', 'created.txt') + + diff('restored\n', undefined, '@@ -1 +0,0 @@\n-restored\n', 'deleted.txt') + }, + { turnId: 'steering-turn', diff: '' }, + ]); + assert.deepStrictEqual([await snapshots(), await snapshots('steering-turn')], [ + [ + { path: expectedPath('created.txt'), kind: FileEditKind.Create, before: undefined, after: 'created\n' }, + { path: expectedPath('deleted.txt'), kind: FileEditKind.Delete, before: 'restored\n', after: undefined }, + ], + [ + { path: expectedPath('created.txt'), kind: FileEditKind.Delete, before: 'created\n', after: undefined }, + { path: expectedPath('deleted.txt'), kind: FileEditKind.Create, before: undefined, after: 'restored\n' }, + ], + ]); + }); + + test('native history reads every host segment while a host id stays scoped', async () => { + await write('c\n'); + const final = diff('a\n', 'c\n', '@@ -1 +1 @@\n-a\n+c\n'); + await capture(final, [ + { turnId: 'host-turn', diff: diff('a\n', 'b\n', '@@ -1 +1 @@\n-a\n+b\n') }, + { turnId: 'steering-turn', diff: final }, + ]); + assert.deepStrictEqual(await snapshots('native-turn'), [...await snapshots(), ...await snapshots('steering-turn')]); + }); + + test('a steering checkpoint starts after the earlier child edit on the shared path', async () => { + const first = diff('original\n', 'parent\n', '@@ -1 +1 @@\n-original\n+parent\n'); + const child = diff('parent\n', 'child\n', '@@ -1 +1 @@\n-parent\n+child\n'); + const last = diff('original\n', 'last\n', '@@ -1 +1 @@\n-original\n+last\n'); + await write('last\n'); + await storeCodexTurnDiffs([ + { + notification: { threadId: 'parent', turnId: 'parent-native', diff: last }, initialDiffs: [first], snapshots: [ + { turnId: 'host-turn', diff: first, startSequence: 1, sequence: 2, fileSequences: fileSequences(['edit.txt'], 2) }, + { turnId: 'steering-turn', diff: last, startSequence: 4, sequence: 5, fileSequences: fileSequences(['edit.txt'], 2, 5) }, + ] + }, + { notification: { threadId: 'child', turnId: 'child-native', diff: child }, initialDiffs: [child], snapshots: [{ turnId: 'host-turn', diff: child, startSequence: 3, sequence: 3, fileSequences: fileSequences(['edit.txt'], 3) }] }, + ], { relatedDiffs: [], cwd, nativeCwd: cwd, database, fileService, logService }); + assert.deepStrictEqual([await snapshots(), await snapshots('steering-turn')], [ + [{ path: expectedPath('edit.txt'), kind: FileEditKind.Edit, before: 'original\n', after: 'child\n' }], + [{ path: expectedPath('edit.txt'), kind: FileEditKind.Edit, before: 'child\n', after: 'last\n' }], + ]); + }); + + test('a child touching a path before its parent supplies the earliest original', async () => { + const child = diff('original\n', 'child\n', '@@ -1 +1 @@\n-original\n+child\n'); + const parent = diff('child\n', 'last\n', '@@ -1 +1 @@\n-child\n+last\n'); + await write('last\n'); + await storeCodexTurnDiffs([ + { notification: { threadId: 'parent', turnId: 'parent-native', diff: parent }, initialDiffs: [parent], snapshots: [{ turnId: 'host-turn', diff: parent, startSequence: 1, sequence: 4, fileSequences: fileSequences(['edit.txt'], 4) }] }, + { notification: { threadId: 'child', turnId: 'child-native', diff: child }, initialDiffs: [child], snapshots: [{ turnId: 'host-turn', diff: child, startSequence: 2, sequence: 3, fileSequences: fileSequences(['edit.txt'], 3) }] }, + ], { relatedDiffs: [], cwd, nativeCwd: cwd, database, fileService, logService }); + assert.deepStrictEqual(await snapshots(), [{ path: expectedPath('edit.txt'), kind: FileEditKind.Edit, before: 'original\n', after: 'last\n' }]); + }); + + test('an unchanged parent path in a later host segment cannot overwrite its child edit', async () => { + const first = diff('original\n', 'parent\n', '@@ -1 +1 @@\n-original\n+parent\n'); + const child = diff('parent\n', 'child\n', '@@ -1 +1 @@\n-parent\n+child\n'); + await write('child\n'); + await storeCodexTurnDiffs([ + { + notification: { threadId: 'parent', turnId: 'parent-native', diff: first }, initialDiffs: [first], snapshots: [ + { turnId: 'host-turn', diff: first, startSequence: 1, sequence: 2, fileSequences: fileSequences(['edit.txt'], 2) }, + { turnId: 'steering-turn', diff: first, startSequence: 4, sequence: 7, fileSequences: fileSequences(['edit.txt'], 2) }, + ] + }, + { notification: { threadId: 'child', turnId: 'child-native', diff: child }, initialDiffs: [child], snapshots: [{ turnId: 'steering-turn', diff: child, startSequence: 5, sequence: 6, fileSequences: fileSequences(['edit.txt'], 6) }] }, + ], { relatedDiffs: [], cwd, nativeCwd: cwd, database, fileService, logService }); + assert.deepStrictEqual([await snapshots(), await snapshots('steering-turn')], [ + [{ path: expectedPath('edit.txt'), kind: FileEditKind.Edit, before: 'original\n', after: 'parent\n' }], + [{ path: expectedPath('edit.txt'), kind: FileEditKind.Edit, before: 'parent\n', after: 'child\n' }], + ]); + }); + + for (const [name, parentAfter, parentSequence, expected] of [ + ['child writes last', 'parent\n', 2, [{ path: expectedPath('edit.txt'), kind: FileEditKind.Edit, before: 'original\n', after: 'child\n' }]], + ['parent writes last', 'last\n', 5, [{ path: expectedPath('edit.txt'), kind: FileEditKind.Edit, before: 'original\n', after: 'last\n' }]], + ['parent reverts the shared file', 'original\n', 5, []], + ] as const) { + test(`shared parent and child paths retain the original when the ${name}`, async () => { + const first = diff('original\n', 'parent\n', '@@ -1 +1 @@\n-original\n+parent\n'); + const child = diff('parent\n', 'child\n', '@@ -1 +1 @@\n-parent\n+child\n'); + const parent = parentAfter === 'original\n' ? '' : diff('original\n', parentAfter, `@@ -1 +1 @@\n-original\n+${parentAfter}`); + await write(parentSequence > 4 ? parentAfter : 'child\n'); + await storeCodexTurnDiffs([ + { notification: { threadId: 'parent', turnId: 'parent-native', diff: parent }, initialDiffs: [first], snapshots: [{ turnId: 'host-turn', diff: parent, startSequence: 1, sequence: parentSequence, fileSequences: fileSequences(['edit.txt'], 2, parentSequence) }] }, + { notification: { threadId: 'child', turnId: 'child-native', diff: child }, initialDiffs: [child], snapshots: [{ turnId: 'host-turn', diff: child, startSequence: 3, sequence: 4, fileSequences: fileSequences(['edit.txt'], 4) }] }, + ], { relatedDiffs: [], cwd, nativeCwd: cwd, database, fileService, logService }); + assert.deepStrictEqual(await snapshots(), expected); + }); + } +}); + +suite('Codex turn diff readiness', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('all steering segments wait for persistence after native completion', async () => { + const stored = new DeferredPromise(); + const tracker = disposables.add(new CodexTurnDiffTracker(() => stored.p)); + tracker.start('thread', 'native', 'first'); + tracker.split('native', 'second'); + const observed: string[] = []; + const waits = ['first', 'second'].map(async id => { await tracker.waitForTurnEdits(id); observed.push(id); }); + const completion = tracker.complete('native'); + await Promise.resolve(); + assert.deepStrictEqual(observed, []); + stored.complete(); + await Promise.all([...waits, completion]); + assert.deepStrictEqual(observed, ['first', 'second']); + }); + + test('persistence failure rejects existing and future checkpoint reads', async () => { + const tracker = disposables.add(new CodexTurnDiffTracker(async () => { throw new Error('store failed'); })); + tracker.start('thread', 'native', 'host'); + const waiting = assert.rejects(tracker.waitForTurnEdits('host'), /store failed/); + await assert.rejects(tracker.complete('native'), /store failed/); + await waiting; + await assert.rejects(tracker.waitForTurnEdits('host'), /store failed/); + }); + + test('a parent checkpoint waits for children that start after its read begins', async () => { + const stored: Array<{ turnId: string; host: string[] }> = []; + const tracker = disposables.add(new CodexTurnDiffTracker(async captures => { + stored.push(...captures.map(capture => ({ turnId: capture.notification.turnId, host: capture.snapshots.map(snapshot => snapshot.turnId) }))); + })); + tracker.start('parent-thread', 'parent-native', 'original-host'); + let ready = false; + const waiting = tracker.waitForTurnEdits('original-host').then(() => { ready = true; }); + tracker.start('child-thread', 'child-native', 'original-host'); + const parentComplete = tracker.complete('parent-native'); + tracker.start('parent-thread', 'replacement-native', 'replacement-host'); + await Promise.resolve(); + assert.strictEqual(ready, false); + await tracker.complete('child-native'); + await parentComplete; + await waiting; + assert.deepStrictEqual(stored.sort((a, b) => b.turnId.localeCompare(a.turnId)), [ + { turnId: 'parent-native', host: ['original-host'] }, + { turnId: 'child-native', host: ['original-host'] }, + ]); + }); + + test('disposing a runtime cancels pending checkpoint reads', async () => { + const tracker = disposables.add(new CodexTurnDiffTracker(async () => { })); + tracker.start('thread', 'native', 'host'); + const waiting = assert.rejects(tracker.waitForTurnEdits('host'), /Canceled/); + tracker.dispose(); + await waiting; + }); + + for (const receivesDiff of [false, true]) { + test(`disconnect completes a child without runtime turn identity (${receivesDiff ? 'with diff' : 'before start'})`, async () => { + const stored: string[] = []; + const tracker = disposables.add(new CodexTurnDiffTracker(async captures => { stored.push(...captures.map(capture => capture.notification.turnId)); })); + tracker.start('parent-thread', 'parent-native', 'host'); + tracker.trackChild('child-thread', 'host'); + if (receivesDiff) { + tracker.update({ threadId: 'child-thread', turnId: 'child-native', diff: '' }); + } + const parent = tracker.complete('parent-native'); + await tracker.completeThread('child-thread'); + await parent; + tracker.start('parent-thread', 'replacement-native', 'replacement-host'); + await tracker.complete('replacement-native'); + assert.deepStrictEqual(stored.sort(), receivesDiff ? ['child-native', 'parent-native', 'replacement-native'] : ['parent-native', 'replacement-native']); + }); + } + + test('a timed-out child fails its checkpoint and releases later persistence', async () => { + const stored: string[] = []; + const tracker = disposables.add(new CodexTurnDiffTracker(async captures => { stored.push(...captures.map(capture => capture.notification.turnId)); })); + tracker.start('parent', 'cancelled-native', 'cancelled-host'); + tracker.trackChild('child', 'cancelled-host'); + const cancelled = assert.rejects(tracker.complete('cancelled-native'), /timed out/); + const waiting = assert.rejects(tracker.waitForTurnEdits('cancelled-host'), /timed out/); + tracker.start('parent', 'replacement-native', 'replacement-host'); + const replacement = tracker.complete('replacement-native'); + tracker.failWaitingTurn('cancelled-host', new Error('timed out')); + await Promise.all([cancelled, waiting, replacement]); + await assert.rejects(tracker.waitForTurnEdits('cancelled-host'), /timed out/); + assert.deepStrictEqual(stored, ['replacement-native']); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index fca60430799f28..7472822d30e3d1 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -8,6 +8,7 @@ import { mock } from '../../../../base/test/common/mock.js'; import type Anthropic from '@anthropic-ai/sdk'; import type { CCAModel } from '@vscode/copilot-api'; import assert from 'assert'; +import { agentEntitlementHintMeta } from '../../common/meta/agentEntitlementHintMeta.js'; import { spy, stub, useFakeTimers } from 'sinon'; import { workspacelessChatsRoot, workspacelessScratchDir } from '../../common/workspacelessScratchDir.js'; import { isCustomizationEnabled } from '../../common/customizationEnablement.js'; @@ -62,7 +63,7 @@ import { readAgentModelAutoTierPromotionsMeta, readAgentModelAutoTiersMeta, read import { TestExperimentTriggerTelemetryService } from '../../../telemetry/test/common/experimentTriggerTestUtils.js'; import { AgentHostConfigKey } from '../../common/agentHostCustomizationConfig.js'; import { buildAgentHostOTelEnv } from '../../common/agentService.js'; -import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostByokModelsEnabledConfigKey, AgentHostCanvasesEnabledConfigKey, AgentHostGitHubMcpServerEnabledConfigKey, AgentHostMcpConnectorsEnabledConfigKey, AgentHostMcpServersConfigKey, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostProxyConfigKey, AgentHostSystemProxyEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AgentHostTelemetryLevelConfigKey, platformSessionSchema, telemetryLevelToAgentHostConfigValue } from '../../common/agentHostSchema.js'; +import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostByokModelsEnabledConfigKey, AgentHostCanvasesEnabledConfigKey, AgentHostGitHubMcpServerEnabledConfigKey, AgentHostMcpConnectorsEnabledConfigKey, AgentHostMcpServersConfigKey, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostProxyConfigKey, AgentHostSystemProxyEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AgentHostTelemetryLevelConfigKey, AgentHostWorkspaceTrustConfigKey, platformSessionSchema, telemetryLevelToAgentHostConfigValue } from '../../common/agentHostSchema.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; import { toClientPluginMcpDefaultCwdsMeta, toClientPluginStandaloneMeta } from '../../common/meta/clientPluginCustomizationMeta.js'; import { getTelemetryChatSessionId } from '../../common/agentTelemetryCorrelation.js'; @@ -1080,6 +1081,7 @@ class MockCopilotSession { sendCalls = 0; disconnectCalls = 0; readonly setModelCalls: Parameters[] = []; + readonly agentSelectionCalls: (string | undefined)[] = []; setModelError: Error | undefined; readonly providerSyncCalls: string[][] = []; /** BYOK `provider/id` selection ids registered at launch or through `provider.sync`. */ @@ -1090,6 +1092,16 @@ class MockCopilotSession { readonly gitHubCredentialUpdates: Array<{ credentials: { type: 'token'; host: string; token: string } }> = []; readonly connectorReconciliations: Parameters[0][] = []; readonly rpc = { + agent: { + select: async ({ name }: { name: string }) => { + this.agentSelectionCalls.push(name); + return {}; + }, + deselect: async () => { + this.agentSelectionCalls.push(undefined); + return {}; + }, + }, sendMessages: async (): Promise => { }, connectors: { getCapabilities: async () => ({ availability: 'enabled', sessionAccountSelection: true }), @@ -1781,6 +1793,10 @@ suite('CopilotAgent', () => { const secondary = URI.file('/workspace/secondary'); const customizationDirectory = URI.file('/workspace/customizations'); const project = { uri: URI.file('/workspace/project'), displayName: 'Project' }; + const projectExtensionTrustGrant = { + worktreeDirectory: URI.file('/workspace/project.worktrees/session'), + repositoryRoot: project.uri, + }; const config = { isolation: 'folder' }; const expected = { 'copilot.model': JSON.stringify(model), @@ -1793,10 +1809,26 @@ suite('CopilotAgent', () => { 'copilot.project.resolved': 'true', 'copilot.project.uri': project.uri.toString(), 'copilot.project.displayName': project.displayName, + 'copilot.projectExtensionTrustGrant': JSON.stringify({ + worktreeDirectory: projectExtensionTrustGrant.worktreeDirectory.toString(), + repositoryRoot: projectExtensionTrustGrant.repositoryRoot.toString(), + }), configValues: JSON.stringify(config), }; try { - await agent['_storeSessionMetadata']({ storage: session, owner: session }, model, primary, [primary, secondary], customizationDirectory, project, true, config, true, true, 'last-migrated-turn'); + await agent['_storeSessionMetadata']({ storage: session, owner: session }, { + model, + workingDirectory: primary, + workingDirectories: [primary, secondary], + customizationDirectory, + project, + projectResolved: true, + configValues: config, + archived: true, + ehcliAdopted: true, + lastMigratedTurnId: 'last-migrated-turn', + projectExtensionTrustGrant, + }); const ownedKeys = [AH_META_IS_ARCHIVED_DB_KEY, 'configValues', 'copilot.customizationDirectory', 'copilot.project.resolved', 'copilot.project.uri', 'copilot.project.displayName']; const owned = Object.fromEntries(Object.entries(expected).filter(([key]) => ownedKeys.includes(key))); @@ -1845,7 +1877,14 @@ suite('CopilotAgent', () => { const mirrorWrites = spy(ref.object, 'applyMetadataChanges'); const expected = { 'copilot.workingDirectories': '[]', configValues: '{}' }; try { - await agent['_storeSessionMetadata']({ storage: session, owner: session }, undefined, undefined, [], undefined, undefined, false, {}, false, false, ''); + await agent['_storeSessionMetadata']({ storage: session, owner: session }, { + workingDirectories: [], + projectResolved: false, + configValues: {}, + archived: false, + ehcliAdopted: false, + lastMigratedTurnId: '', + }); assert.deepStrictEqual({ mirroredOwned: mirrorWrites.firstCall?.args[0], @@ -1872,7 +1911,12 @@ suite('CopilotAgent', () => { const batchedWrites = spy(ref.object, 'setMetadataValues'); const primary = URI.file('/workspace/peer'); try { - await agent['_storeSessionMetadata']({ storage: chat, owner: AgentSession.uri('copilotcli', 'owner') }, undefined, primary, [primary], undefined, undefined, false, { isolation: 'folder' }); + await agent['_storeSessionMetadata']({ storage: chat, owner: AgentSession.uri('copilotcli', 'owner') }, { + workingDirectory: primary, + workingDirectories: [primary], + projectResolved: false, + configValues: { isolation: 'folder' }, + }); assert.deepStrictEqual({ batched: batchedWrites.getCalls().map(call => call.args[0]), @@ -1903,7 +1947,10 @@ suite('CopilotAgent', () => { const sessionStateWrites = spy(agent['_sessionStateStore'], 'writeMetadata'); const primary = URI.file('/workspace/moved'); try { - await agent['_storeSessionMetadata']({ storage, owner }, undefined, primary, [primary], undefined, undefined); + await agent['_storeSessionMetadata']({ storage, owner }, { + workingDirectory: primary, + workingDirectories: [primary], + }); const read = await agent['_readSessionMetadata']({ storage, owner }); assert.deepStrictEqual({ @@ -1937,7 +1984,7 @@ suite('CopilotAgent', () => { const individualWrites = spy(ref.object, 'setMetadata'); const batchedWrites = spy(ref.object, 'setMetadataValues'); try { - await agent['_storeSessionMetadata']({ storage: session, owner: session }, undefined, undefined, undefined, undefined, undefined); + await agent['_storeSessionMetadata']({ storage: session, owner: session }, {}); assert.deepStrictEqual({ individualWrites: individualWrites.callCount, @@ -1968,7 +2015,11 @@ suite('CopilotAgent', () => { const openDatabase = stub(sessionDataService, 'openDatabase').returns(ref); try { await assert.rejects( - agent['_storeSessionMetadata']({ storage: session, owner: session }, undefined, URI.file('/workspace/next'), undefined, undefined, undefined, false, { isolation: 'worktree' }), + agent['_storeSessionMetadata']({ storage: session, owner: session }, { + workingDirectory: URI.file('/workspace/next'), + projectResolved: false, + configValues: { isolation: 'worktree' }, + }), error => error === failure, ); await ref.object.whenIdle(); @@ -3524,16 +3575,17 @@ suite('CopilotAgent', () => { let initializationCalls = 0; let registered = false; const internals = agent as unknown as { - _acquireExtensionLaunchAdmission(directories: readonly URI[]): Promise; + _acquireProjectExtensionLaunchAdmission(session: CopilotAgentSession): Promise; _initializeAndRegisterSession(session: CopilotAgentSession, register: () => void): Promise; }; - internals._acquireExtensionLaunchAdmission = async () => { + internals._acquireProjectExtensionLaunchAdmission = async () => { admissionStarted.complete(); await releaseAdmission.p; return toDisposable(() => { admissionDisposed = true; }); }; const session = { extensionLaunchDirectories: [URI.file('/workspace')], + projectExtensionTrustGrant: undefined, setExtensionLaunchAdmission: () => { admissionAttached = true; }, initializeSession: async () => { initializationCalls++; }, dispose: () => { sessionDisposed = true; }, @@ -4225,7 +4277,7 @@ suite('CopilotAgent', () => { }(); const copilotApiService = disposables.add(new CopilotApiService(async () => Response.json({ endpoints: { api: 'https://api.githubcopilot.com' }, access_type_sku: 'sku', analytics_tracking_id: 'analytics', - }), new NullLogService(), TEST_PRODUCT_SERVICE, createTestGitHubEndpointService())); + }), new NullLogService(), TEST_PRODUCT_SERVICE, createTestGitHubEndpointService(), NullTelemetryService)); const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]), telemetryService, copilotApiService }); try { await agent.authenticate('https://api.github.com', 'token-a'); @@ -5636,7 +5688,7 @@ suite('CopilotAgent', () => { return copilotDiscovery.p; } return Response.json({ endpoints: { api: 'https://api.githubcopilot.com' }, access_type_sku: 'codex-sku', analytics_tracking_id: 'codex-analytics' }); - }, new NullLogService(), TEST_PRODUCT_SERVICE, endpoints)); + }, new NullLogService(), TEST_PRODUCT_SERVICE, endpoints, NullTelemetryService)); const events: ITelemetryData[] = []; const sdkEvents: ITelemetryData[] = []; const telemetryService = disposables.add(new AgentHostTelemetryService(TelemetryService.createWithLevel({ @@ -5720,7 +5772,7 @@ suite('CopilotAgent', () => { oldCredentialReachedNewEndpoint ||= new Headers(options?.headers).get('Authorization') === 'Bearer test-token-a'; } return Response.json({ endpoints: { api: 'https://api.githubcopilot.com' }, access_type_sku: 'enterprise-sku' }); - }, logService, TEST_PRODUCT_SERVICE, endpoints)); + }, logService, TEST_PRODUCT_SERVICE, endpoints, NullTelemetryService)); const skus: ITelemetryData[] = []; const telemetryService = disposables.add(new AgentHostTelemetryService(TelemetryService.createWithLevel({ telemetryLevel: TelemetryLevel.USAGE, @@ -5728,7 +5780,7 @@ suite('CopilotAgent', () => { }, TEST_PRODUCT_SERVICE))); const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]), copilotApiService: apiService, gitHubEndpointService: endpoints, telemetryService }); try { - const oldAuthentication = agent.authenticate(endpoints.getCopilotResource().resource, 'test-token-a'); + const oldAuthentication = agent.authenticate(endpoints.getCopilotResource().resource, 'test-token-a', undefined, agentEntitlementHintMeta({ trackingId: 'analytics', sku: 'free' })); await discoveryStarted.p; configuration.updateRootConfig({ [AgentHostConfigKey.GithubEnterpriseUri]: 'https://acme.ghe.com' }); const newAuthentication = agent.authenticate(endpoints.getCopilotResource().resource, 'test-token-b'); @@ -5749,7 +5801,7 @@ suite('CopilotAgent', () => { let available = false; const copilotApiService = disposables.add(new CopilotApiService(async () => available ? Response.json({ endpoints: { api: 'https://api.githubcopilot.com' }, access_type_sku: 'recovered-sku' }) - : new Response('Unavailable', { status: 503 }), new NullLogService(), TEST_PRODUCT_SERVICE, createTestGitHubEndpointService())); + : new Response('Unavailable', { status: 503 }), new NullLogService(), TEST_PRODUCT_SERVICE, createTestGitHubEndpointService(), NullTelemetryService)); const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]), copilotApiService }); try { await agent.authenticate('https://api.github.com', 'test-token-a'); @@ -5767,6 +5819,59 @@ suite('CopilotAgent', () => { } }); + test('refreshes same-token discovery for matching account entitlement hints without trusting their SKU', async () => { + let sku = 'no_access'; + const copilotApiService = disposables.add(new CopilotApiService(async () => Response.json({ + endpoints: { api: 'https://api.githubcopilot.com' }, access_type_sku: sku, analytics_tracking_id: 'analytics', + }), new NullLogService(), TEST_PRODUCT_SERVICE, createTestGitHubEndpointService(), NullTelemetryService)); + const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]), copilotApiService }); + const refresh = spy(copilotApiService, 'refreshCopilotUser'); + try { + await agent.authenticate('https://api.github.com', 'token'); + const before = agent.getTelemetryContext().copilotSku; + sku = 'server-sku'; + await agent.authenticate('https://api.github.com', 'token', undefined, agentEntitlementHintMeta({ trackingId: 'other', sku: 'free' })); + const unrelated = agent.getTelemetryContext().copilotSku; + await agent.authenticate('https://api.github.com', 'token', undefined, agentEntitlementHintMeta({ trackingId: 'analytics', sku: 'free' })); + assert.deepStrictEqual(refresh.args, [ + ['token', 'other', 'free'], + ['token', 'analytics', 'free'], + ]); + await refresh.lastCall.returnValue; + assert.deepStrictEqual({ before, unrelated, refreshed: agent.getTelemetryContext().copilotSku }, { + before: 'no_access', unrelated: 'no_access', refreshed: 'server-sku', + }); + } finally { + refresh.restore(); + await disposeAgent(agent); + } + }); + + test('pending entitlement refresh does not block same-token authentication or revocation', async () => { + const refresh = new DeferredPromise(); + const refreshStarted = new DeferredPromise(); + let requests = 0; + const copilotApiService = disposables.add(new CopilotApiService(async () => { + if (++requests === 1) { + return Response.json({ endpoints: { api: 'https://api.githubcopilot.com' }, access_type_sku: 'no_access', analytics_tracking_id: 'analytics' }); + } + refreshStarted.complete(); + return refresh.p; + }, new NullLogService(), TEST_PRODUCT_SERVICE, createTestGitHubEndpointService(), NullTelemetryService)); + const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]), copilotApiService }); + try { + await agent.authenticate('https://api.github.com', 'token'); + await agent.authenticate('https://api.github.com', 'token', undefined, agentEntitlementHintMeta({ trackingId: 'analytics', sku: 'free' })); + await refreshStarted.p; + await agent.authenticate('https://api.github.com', 'token'); + await agent.authenticate('https://api.github.com', ''); + assert.deepStrictEqual({ sku: agent.getTelemetryContext().copilotSku, requests }, { sku: undefined, requests: 2 }); + } finally { + refresh.complete(Response.json({ endpoints: { api: 'https://api.githubcopilot.com' }, access_type_sku: 'free', analytics_tracking_id: 'analytics' })); + await disposeAgent(agent); + } + }); + suite('scoped credentials', () => { function setup(sessionDataService = disposables.add(new TestSessionDataService()), metadata: TestCopilotSessionMetadata[] = [], history: SessionEvent[] = [], rootConfig?: Record, fixture?: { telemetryService?: ITelemetryService; logService?: ILogService; onClientCreated?: (client: TestCopilotClient, options: CopilotClientOptions) => void }) { const { agent, configurationService } = createTestAgentContext(disposables, { copilotClient: new TestCopilotClient([]), sessionDataService, useRealResumePath: true, rootConfig, telemetryService: fixture?.telemetryService, logService: fixture?.logService }); @@ -5881,6 +5986,161 @@ suite('CopilotAgent', () => { } }); + for (const transition of ['legacy', 'rotated', 'expired'] as const) { + for (const selection of ['model', 'agent'] as const) { + test(`${selection}-first selection adopts the current sponsor before sending with a ${transition} runtime`, async () => { + const clock = useFakeTimers({ now: Date.now(), toFake: ['Date'] }); + disposables.add(toDisposable(() => clock.restore())); + const { agent, clients, options, launches } = setup(); + const session = AgentSession.uri('copilotcli', `scoped-selection-${transition}-${selection}`); + const chat = defaultChatUri(session); + const directory = URI.file(process.cwd()); + const principal = { apiOrigin: 'https://api.github.com', userId: '1' }; + const firstSource = { + principal, + getCredential: () => ({ resource: principal.apiOrigin, token: 'ios', expiresAt: transition === 'expired' ? clock.now + 60_000 : undefined, revision: 1 }), + }; + const localSource = { + principal, + getCredential: () => ({ resource: principal.apiOrigin, token: 'desktop', expiresAt: undefined, revision: 2 }), + }; + const context = exactChatContext(session, chat, session); + const firstContext = transition === 'legacy' ? context : { ...context, credentials: firstSource }; + const localContext = { ...context, credentials: localSource }; + try { + await provisionSession(agent, { session, credentials: firstContext.credentials, model: { id: 'gpt-4o' }, workingDirectories: [directory] }); + await agent.chats.sendMessage(chat, 'first', [directory], undefined, 'first', undefined, firstContext); + const original = launches[0]; + chatEntriesBySdkId(agent).get(original.sessionId)!.chatSession.discardActiveTurn(); + if (transition === 'expired') { + clock.tick(60_001); + } + for (const client of clients) { + client.managedSettingsRequests.length = 0; + } + + const changeModel = () => agent.chats.changeModel(chat, { id: 'gpt-4o' }, localContext); + const changeAgent = () => agent.chats.changeAgent(chat, undefined, localContext); + await Promise.all(selection === 'model' ? [changeModel(), changeAgent()] : [changeAgent(), changeModel()]); + await agent.chats.prepareTurn!(chat, 'continued', [directory], localContext); + await agent.chats.sendMessage(chat, 'continued', [directory], undefined, 'continued', undefined, localContext); + + assert.deepStrictEqual({ + tokens: launches.map(launch => launch.config.gitHubToken), + backingPreserved: launches.every(launch => launch.sessionId === original.sessionId), + sends: launches.map(launch => launch.runtime.sendCalls), + models: launches.map(launch => launch.runtime.setModelCalls.map(([model]) => model)), + agents: launches.map(launch => launch.runtime.agentSelectionCalls), + policyTokens: [...new Set(clients.flatMap(client => client.managedSettingsRequests.map(request => request.gitHubToken)))], + stops: clients.filter((_client, index) => options[index].gitHubToken === 'ios').map(client => client.stopCallCount), + }, { + tokens: [transition === 'legacy' ? undefined : 'ios', 'desktop'], + backingPreserved: true, + sends: [1, 1], + models: [[], ['gpt-4o']], + agents: [[], [undefined]], + policyTokens: ['desktop'], + stops: transition === 'legacy' ? [] : [1], + }); + } finally { + await disposeAgent(agent); + } + }); + } + } + + for (const selection of ['model', 'agent'] as const) { + test(`${selection} selection rejects an unavailable sponsor without using the pinned or host credential`, async () => { + const { agent, clients, launches } = setup(); + const session = AgentSession.uri('copilotcli', `scoped-selection-unavailable-${selection}`); + const chat = defaultChatUri(session); + const directory = URI.file(process.cwd()); + const firstSource = { getCredential: () => ({ resource: 'https://api.github.com', token: 'ios', expiresAt: undefined, revision: 1 }) }; + const context = { ...exactChatContext(session, chat, session), credentials: firstSource }; + try { + await provisionSession(agent, { session, credentials: firstSource, model: { id: 'gpt-4o' }, workingDirectories: [directory] }); + await agent.chats.sendMessage(chat, 'first', [directory], undefined, 'first', undefined, context); + chatEntriesBySdkId(agent).get(launches[0].sessionId)!.chatSession.discardActiveTurn(); + for (const client of clients) { + client.managedSettingsRequests.length = 0; + } + const unavailableContext = { ...context, credentials: { getCredential: () => undefined } }; + + await assert.rejects(selection === 'model' + ? agent.chats.changeModel(chat, { id: 'gpt-4o' }, unavailableContext) + : agent.chats.changeAgent(chat, undefined, unavailableContext), { code: AhpErrorCodes.AuthRequired }); + + assert.deepStrictEqual({ + launches: launches.length, + models: launches[0].runtime.setModelCalls, + agents: launches[0].runtime.agentSelectionCalls, + policyRequests: clients.flatMap(client => client.managedSettingsRequests), + }, { launches: 1, models: [], agents: [], policyRequests: [] }); + } finally { + await disposeAgent(agent); + } + }); + + test(`${selection} selection does not replace a busy runtime with another sponsor`, async () => { + const { agent, clients, launches } = setup(); + const session = AgentSession.uri('copilotcli', `scoped-selection-busy-${selection}`); + const chat = defaultChatUri(session); + const directory = URI.file(process.cwd()); + const firstSource = { getCredential: () => ({ resource: 'https://api.github.com', token: 'ios', expiresAt: undefined, revision: 1 }) }; + const context = { ...exactChatContext(session, chat, session), credentials: firstSource }; + try { + await provisionSession(agent, { session, credentials: firstSource, model: { id: 'gpt-4o' }, workingDirectories: [directory] }); + await agent.chats.sendMessage(chat, 'first', [directory], undefined, 'first', undefined, context); + const replacementContext = { + ...context, + credentials: { getCredential: () => ({ resource: 'https://api.github.com', token: 'desktop', expiresAt: undefined, revision: 2 }) }, + }; + + await assert.rejects(selection === 'model' + ? agent.chats.changeModel(chat, { id: 'gpt-4o' }, replacementContext) + : agent.chats.changeAgent(chat, undefined, replacementContext), /active turn/i); + + assert.deepStrictEqual({ + tokens: launches.map(launch => launch.config.gitHubToken), + stops: clients.map(client => client.stopCallCount), + models: launches[0].runtime.setModelCalls, + agents: launches[0].runtime.agentSelectionCalls, + }, { tokens: ['ios'], stops: clients.map(() => 0), models: [], agents: [] }); + } finally { + await disposeAgent(agent); + } + }); + } + + test('selection and send keep an untouched local chat on the legacy credential path', async () => { + const { agent, options, launches } = setup(); + const session = AgentSession.uri('copilotcli', 'legacy-selection'); + const chat = defaultChatUri(session); + const directory = URI.file(process.cwd()); + const context = exactChatContext(session, chat, session); + try { + await provisionSession(agent, { session, model: { id: 'gpt-4o' }, workingDirectories: [directory] }); + await agent.chats.sendMessage(chat, 'first', [directory], undefined, 'first', undefined, context); + chatEntriesBySdkId(agent).get(launches[0].sessionId)!.chatSession.discardActiveTurn(); + + await Promise.all([ + agent.chats.changeModel(chat, { id: 'gpt-4o' }, context), + agent.chats.changeAgent(chat, undefined, context), + ]); + await agent.chats.sendMessage(chat, 'continued', [directory], undefined, 'continued', undefined, context); + + assert.deepStrictEqual({ + tokens: launches.map(launch => launch.config.gitHubToken), + scopedClients: options.filter(option => option.gitHubToken !== undefined).length, + sends: launches.map(launch => launch.runtime.sendCalls), + models: launches[0].runtime.setModelCalls.map(([model]) => model), + agents: launches[0].runtime.agentSelectionCalls, + }, { tokens: [undefined], scopedClients: 0, sends: [2], models: ['gpt-4o'], agents: [] }); + } finally { + await disposeAgent(agent); + } + }); + test('defers stale configuration while a subagent is busy, but rejects a different sponsor', async () => { const { agent, clients, options, launches, configurationService } = setup(); const session = AgentSession.uri('copilotcli', 'scoped-subagent-config'); @@ -7227,7 +7487,7 @@ suite('CopilotAgent', () => { const sessionDataService = disposables.add(new TestSessionDataService()); const copilotApiService = disposables.add(new CopilotApiService(async () => Response.json({ endpoints: { api: 'https://api.githubcopilot.com' }, access_type_sku: 'sku-a', - }), new NullLogService(), TEST_PRODUCT_SERVICE, createTestGitHubEndpointService())); + }), new NullLogService(), TEST_PRODUCT_SERVICE, createTestGitHubEndpointService(), NullTelemetryService)); const { agent, instantiationService } = createTestAgentContext(disposables, { sessionDataService, copilotApiService }); const mockSession = new MockCopilotSession(); const createdSession = createAgentSessionThroughAgent(agent, instantiationService, { mockSession }); @@ -10695,6 +10955,7 @@ suite('CopilotAgent', () => { { key: 'copilot.project.resolved', value: 'true' }, { key: 'copilot.workingDirectory', value: next.toString() }, { key: 'copilot.workingDirectories', value: JSON.stringify([next.toString()]) }, + { key: 'copilot.projectExtensionTrustGrant', value: '' }, { key: 'copilot.customizationDirectory', value: previous.toString() }, { key: 'copilot.sharedCustomizationDirectories', value: '' }, { key: 'copilot.project.uri', value: '' }, @@ -10702,6 +10963,7 @@ suite('CopilotAgent', () => { { key: 'copilot.project.resolved', value: '' }, { key: 'copilot.workingDirectory', value: previous.toString() }, { key: 'copilot.workingDirectories', value: JSON.stringify([previous.toString()]) }, + { key: 'copilot.projectExtensionTrustGrant', value: '' }, ], metadata: { 'copilot.workingDirectory': previous.toString(), @@ -10789,6 +11051,7 @@ suite('CopilotAgent', () => { { key: 'copilot.project.resolved', value: 'true' }, { key: 'copilot.workingDirectory', value: next.toString() }, { key: 'copilot.workingDirectories', value: JSON.stringify([next.toString()]) }, + { key: 'copilot.projectExtensionTrustGrant', value: '' }, { key: 'copilot.customizationDirectory', value: previous.toString() }, { key: 'copilot.sharedCustomizationDirectories', value: '' }, { key: 'copilot.project.uri', value: previous.toString() }, @@ -10796,6 +11059,7 @@ suite('CopilotAgent', () => { { key: 'copilot.project.resolved', value: 'true' }, { key: 'copilot.workingDirectory', value: previous.toString() }, { key: 'copilot.workingDirectories', value: JSON.stringify([previous.toString()]) }, + { key: 'copilot.projectExtensionTrustGrant', value: '' }, ], metadata: { 'copilot.workingDirectory': previous.toString(), @@ -10885,6 +11149,7 @@ suite('CopilotAgent', () => { { key: 'copilot.project.resolved', value: 'true' }, { key: 'copilot.workingDirectory', value: next.toString() }, { key: 'copilot.workingDirectories', value: JSON.stringify([next.toString()]) }, + { key: 'copilot.projectExtensionTrustGrant', value: '' }, { key: 'copilot.customizationDirectory', value: actual.toString() }, { key: 'copilot.sharedCustomizationDirectories', value: '' }, { key: 'copilot.project.uri', value: '' }, @@ -10892,6 +11157,7 @@ suite('CopilotAgent', () => { { key: 'copilot.project.resolved', value: 'true' }, { key: 'copilot.workingDirectory', value: actual.toString() }, { key: 'copilot.workingDirectories', value: JSON.stringify([actual.toString()]) }, + { key: 'copilot.projectExtensionTrustGrant', value: '' }, ], metadata: { 'copilot.workingDirectory': actual.toString(), @@ -19173,6 +19439,21 @@ suite('CopilotAgent', () => { } }); + test('a first send that selects the model the draft was created with claims the warm launch', async () => { + // Every first send selects its model before it runs (`chat/turnStarted`), including an unchanged one. + const model = { id: 'claude-sonnet', config: { thinkingLevel: 'high' } }; + const h = await createWarmableAgent('warm-same-model', undefined, { model }); + try { + await h.agent.chats.warmChat!(h.chat, h.context); + await h.agent.chats.changeModel(h.chat, { ...model, config: { ...model.config } }, h.context); + await h.agent.chats.sendMessage(h.chat, 'hello', [workingDirectory], undefined, 'turn-1', undefined, h.sendContext); + + assert.deepStrictEqual(h.state(), { creates: 1, mcpLists: 1, materialized: 1, prewarm: ['used'], deleted: 0, live: true }); + } finally { + await disposeAgent(h.agent); + } + }); + test('a model option change before the first send starts it cold instead of applying it live', async () => { const h = await createWarmableAgent('warm-model-config-change', undefined, { model: { id: 'claude-sonnet' } }); try { @@ -20354,7 +20635,7 @@ suite('CopilotAgent', () => { let capturedConfig: Parameters[0] | undefined; const copilotApiService = disposables.add(new CopilotApiService(async () => Response.json({ endpoints: { api: 'https://api.githubcopilot.com' }, access_type_sku: 'sku-a', - }), new NullLogService(), TEST_PRODUCT_SERVICE, createTestGitHubEndpointService())); + }), new NullLogService(), TEST_PRODUCT_SERVICE, createTestGitHubEndpointService(), NullTelemetryService)); const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client, copilotApiService }); client.createSession = async config => { capturedConfig = config; @@ -24235,8 +24516,10 @@ suite('CopilotAgent', () => { * `sessionId` echoes the launch plan, which is what `createChat` records * as the chat's backing. */ - function stubBackingSession(agent: CopilotAgent): void { + function stubBackingSession(agent: CopilotAgent): CopilotSessionLaunchPlan[] { + const launchPlans: CopilotSessionLaunchPlan[] = []; (agent as unknown as ConvInternals)._createAgentSession = (launchPlan, _dir, _ac, identity) => { + launchPlans.push(launchPlan); const sessionUri = identity?.owner.get() ?? AgentSession.uri('copilotcli', launchPlan.sessionId); const chatChannelUri = identity?.chatChannelUri ?? defaultChatUri(sessionUri); return { @@ -24257,6 +24540,7 @@ suite('CopilotAgent', () => { dispose(): void { launchPlan.shellManager?.dispose(); }, } as unknown as CopilotAgentSession; }; + return launchPlans; } test('one createChat entry serves both a deferred first backing and an eager additional backing', async () => { @@ -24309,6 +24593,101 @@ suite('CopilotAgent', () => { } }); + test('eager peer creation inherits extension trust without a live parent runtime', async () => { + const tempRoot = await fs.mkdtemp(`${os.tmpdir()}/peer-worktree-extension-`); + const ownerRepository = URI.file(join(tempRoot, 'owner')); + const peerRepository = URI.file(join(tempRoot, 'peer')); + const worktree = URI.file(join(tempRoot, 'peer.worktrees', 'session')); + const ordinaryFolder = URI.file(join(tempRoot, 'owner.worktrees', 'ordinary')); + const ownerPrimaryWorktree = URI.file(join(tempRoot, 'owner.worktrees', 'primary')); + await fs.mkdir(ownerRepository.fsPath, { recursive: true }); + await fs.mkdir(peerRepository.fsPath, { recursive: true }); + await fs.mkdir(worktree.fsPath, { recursive: true }); + await fs.mkdir(ordinaryFolder.fsPath, { recursive: true }); + await fs.mkdir(ownerPrimaryWorktree.fsPath, { recursive: true }); + const sessionDataService = disposables.add(new TestSessionDataService()); + const agent = createTestAgent(disposables, { + copilotClient: new TestCopilotClient([]), + sessionDataService, + rootConfig: { + [AgentHostWorkspaceTrustConfigKey]: { enabled: true, trustedUris: [ownerRepository.toString(), peerRepository.toString()] }, + }, + }); + try { + await agent.authenticate('https://api.github.com', 'token'); + const session = AgentSession.uri('copilotcli', 'cold-peer-worktree'); + const peer = URI.parse(buildChatUri(session, 'peer-a')); + const ordinaryPeer = URI.parse(buildChatUri(session, 'peer-ordinary')); + const primaryPeer = URI.parse(buildChatUri(session, 'peer-primary')); + const sessionSettings: IAgentChatSessionSettings = { + ...hostSessionSettings(session)!, + additionalWorktrees: [{ + handle: 'peer-worktree', + workingDirectory: worktree.toString(), + repositoryRoot: peerRepository.toString(), + chat: peer.toString(), + }], + }; + const launchPlans = stubBackingSession(agent); + const ownerDb = sessionDataService.openDatabase(session); + await ownerDb.object.setMetadata('copilot.projectExtensionTrustGrant', JSON.stringify({ + worktreeDirectory: URI.file(await fs.realpath(ownerPrimaryWorktree.fsPath)).toString(), + repositoryRoot: URI.file(await fs.realpath(ownerRepository.fsPath)).toString(), + })); + ownerDb.dispose(); + + await agent.chats.createChat(peer, { ...exactChatContext(session, peer), sessionSettings }, { + workingDirectories: [worktree], + project: { uri: ownerRepository, displayName: 'owner' }, + }); + await agent.chats.createChat(ordinaryPeer, { ...exactChatContext(session, ordinaryPeer), sessionSettings }, { + workingDirectories: [ordinaryFolder], + project: { uri: ownerRepository, displayName: 'owner' }, + config: { [SessionConfigKey.Isolation]: 'worktree' }, + }); + await agent.chats.createChat(primaryPeer, { + ...exactChatContext(session, primaryPeer), + sessionSettings: { + ...sessionSettings, + defaultChatWorkingDirectory: ownerPrimaryWorktree, + additionalWorktrees: [], + }, + }, { + workingDirectories: [ownerPrimaryWorktree], + project: { uri: ownerRepository, displayName: 'owner' }, + }); + const peerDb = sessionDataService.openDatabase(peer); + const persistedProject = await peerDb.object.getMetadata('copilot.project.uri'); + const persistedProjectExtensionTrustGrant = await peerDb.object.getMetadata('copilot.projectExtensionTrustGrant'); + peerDb.dispose(); + + assert.deepStrictEqual({ + launchPlans: launchPlans.map(plan => plan.projectExtensionTrustGrant && { + worktreeDirectory: plan.projectExtensionTrustGrant.worktreeDirectory.toString(), + repositoryRoot: plan.projectExtensionTrustGrant.repositoryRoot.toString(), + }), + persistedProject, + persistedProjectExtensionTrustGrant: persistedProjectExtensionTrustGrant ? JSON.parse(persistedProjectExtensionTrustGrant) : undefined, + }, { + launchPlans: [{ + worktreeDirectory: URI.file(await fs.realpath(worktree.fsPath)).toString(), + repositoryRoot: URI.file(await fs.realpath(peerRepository.fsPath)).toString(), + }, undefined, { + worktreeDirectory: URI.file(await fs.realpath(ownerPrimaryWorktree.fsPath)).toString(), + repositoryRoot: URI.file(await fs.realpath(ownerRepository.fsPath)).toString(), + }], + persistedProject: URI.file(await fs.realpath(peerRepository.fsPath)).toString(), + persistedProjectExtensionTrustGrant: { + worktreeDirectory: URI.file(await fs.realpath(worktree.fsPath)).toString(), + repositoryRoot: URI.file(await fs.realpath(peerRepository.fsPath)).toString(), + }, + }); + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + test('an imported creation reports the exact backing it recorded and no separately enumerable backing session', async () => { const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/import-backing-home-`)); const workingDirectory = URI.file(await fs.mkdtemp(`${os.tmpdir()}/import-backing-cwd-`)); @@ -24934,8 +25313,10 @@ suite('CopilotAgent', () => { const tools: ToolDefinition[] = [{ name: 'my_tool', description: 'A test tool', inputSchema: { type: 'object', properties: {} } }]; - test('identical client plugin content under a different URI does not require a restart', async () => { - const agent = createTestAgent(disposables); + test('identical client plugin content under a different URI does not require a restart while the applied root exists', async () => { + const { agent, fileService } = createTestAgentContext(disposables); + disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new InMemoryFileSystemProvider()))); + await fileService.createFolder(URI.file('/cache/old')); try { const session = AgentSession.uri('copilotcli', 'relocated-plugin-session'); agent.getOrCreateActiveClient(defaultChatUri(session), session, { clientId: 'client' }); @@ -24956,10 +25337,14 @@ suite('CopilotAgent', () => { }], }); const applied = snapshot('/cache/old'); + const relocated = await activeClient.getRestartReason(applied, undefined, snapshot('/cache/new')); + const changed = await activeClient.getRestartReason(applied, undefined, snapshot('/cache/new', 'changed-content')); + await fileService.del(URI.file('/cache/old'), { recursive: true }); assert.deepStrictEqual([ + relocated, + changed, await activeClient.getRestartReason(applied, undefined, snapshot('/cache/new')), - await activeClient.getRestartReason(applied, undefined, snapshot('/cache/new', 'changed-content')), - ], [undefined, 'clientPluginsChanged']); + ], [undefined, 'clientPluginsChanged', 'clientPluginsChanged']); } finally { await disposeAgent(agent); } @@ -26286,6 +26671,111 @@ suite('CopilotAgent', () => { }).timeout(30_000); } + test('resume preserves inherited extension trust for an isolated worktree', async () => { + const tempRoot = await fs.mkdtemp(`${os.tmpdir()}/resume-worktree-extension-`); + const ownerRepository = join(tempRoot, 'owner'); + const peerRepository = join(tempRoot, 'peer'); + const worktree = join(tempRoot, 'peer.worktrees', 'session'); + const extensionDirectory = join(worktree, '.github', 'extensions', 'preview'); + const modulePath = join(extensionDirectory, 'extension.mjs'); + await fs.mkdir(ownerRepository, { recursive: true }); + await fs.mkdir(peerRepository, { recursive: true }); + await fs.mkdir(extensionDirectory, { recursive: true }); + await fs.writeFile(modulePath, ''); + const sessionDataService = disposables.add(new TestSessionDataService()); + const session = AgentSession.uri('copilotcli', 's1'); + const dbRef = sessionDataService.openDatabase(session); + try { + await dbRef.object.setMetadata('copilot.workingDirectory', URI.file(worktree).toString()); + await dbRef.object.setMetadata('copilot.workingDirectories', JSON.stringify([URI.file(worktree).toString()])); + await dbRef.object.setMetadata('copilot.project.resolved', 'true'); + await dbRef.object.setMetadata('copilot.project.uri', URI.file(peerRepository).toString()); + await dbRef.object.setMetadata('copilot.project.displayName', 'peer'); + await dbRef.object.setMetadata('copilot.projectExtensionTrustGrant', JSON.stringify({ + worktreeDirectory: URI.file(await fs.realpath(worktree)).toString(), + repositoryRoot: URI.file(await fs.realpath(peerRepository)).toString(), + })); + } finally { + dbRef.dispose(); + } + const client = new TestCopilotClient([sdkSession('s1', worktree)]); + client.resumeSession = async id => new MockCopilotSession(id) as unknown as CopilotSession; + const agent = createTestAgent(disposables, { + copilotClient: client, + useRealResumePath: true, + sessionDataService, + rootConfig: { + [AgentHostWorkspaceTrustConfigKey]: { enabled: true, trustedUris: [URI.file(ownerRepository).toString(), URI.file(peerRepository).toString()] }, + }, + }); + try { + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); + await (agent as unknown as AgentInternals)._resumeSession('s1', sessionBackedChat('s1')); + const launch = await getCreatedClientOptions(agent).at(-1)?.extensionLaunchProvider?.resolve({ + id: 'project:preview', + name: 'preview', + modulePath, + source: 'project', + }); + const resumedDb = sessionDataService.openDatabase(session); + const persistedProject = await resumedDb.object.getMetadata('copilot.project.uri'); + resumedDb.dispose(); + assert.deepStrictEqual({ + extensionPathMatches: launch?.launch?.env.EXTENSION_PATH + ? isEqual(URI.file(launch.launch.env.EXTENSION_PATH), URI.file(await fs.realpath(modulePath))) + : false, + persistedProject, + }, { + extensionPathMatches: true, + persistedProject: URI.file(await fs.realpath(peerRepository)).toString(), + }); + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }); + await disposeAgent(agent); + } + }).timeout(30_000); + + test('resume leaves unresolved project metadata available for later resolution', async () => { + const root = await fs.mkdtemp(`${os.tmpdir()}/resume-unresolved-project-`); + const workingDirectory = URI.file(join(root, 'workspace')); + await fs.mkdir(workingDirectory.fsPath, { recursive: true }); + const sessionDataService = disposables.add(new TestSessionDataService()); + const session = AgentSession.uri('copilotcli', 'resume-unresolved-project'); + const chat = sessionBackedChat('resume-unresolved-project'); + const dbRef = sessionDataService.openDatabase(session); + await dbRef.object.setMetadata('copilot.workingDirectory', workingDirectory.toString()); + dbRef.dispose(); + const client = new TestCopilotClient([sdkSession('resume-unresolved-project', workingDirectory.fsPath, { repository: 'owner/repository' })]); + client.resumeSession = async id => new MockCopilotSession(id) as unknown as CopilotSession; + const agent = createTestAgent(disposables, { + copilotClient: client, + useRealResumePath: true, + sessionDataService, + }); + try { + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); + await (agent as unknown as AgentInternals)._resumeSession('resume-unresolved-project', chat); + const beforeMetadata = sessionDataService.openDatabase(session); + const resolvedBeforeMetadata = await beforeMetadata.object.getMetadata('copilot.project.resolved'); + beforeMetadata.dispose(); + const metadata = await agent.getChatMetadata(chat, exactChatContext(session, chat, session)); + + assert.deepStrictEqual({ + resolvedBeforeMetadata, + project: metadata?.project && { + uri: metadata.project.uri.toString(), + displayName: metadata.project.displayName, + }, + }, { + resolvedBeforeMetadata: undefined, + project: { uri: 'https://github.com/owner/repository', displayName: 'repository' }, + }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + await disposeAgent(agent); + } + }).timeout(30_000); + test('resume replaces the complete SDK system message when configured', async () => { const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/resume-system-message-`); const promptOverrideFile = '/prompt.yaml'; diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 07c11e81864dca..58dcbf533a6889 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -46,7 +46,7 @@ import { AgentFeedbackAttachmentDisplayKind } from '../../common/meta/agentFeedb import { ChatInputRequestPurpose, readChatInputRequestPurpose } from '../../common/meta/agentChatInputRequestMeta.js'; import { readToolCallMeta } from '../../common/meta/agentToolCallMeta.js'; import { readImageGenerationToolMetadata } from '../../common/meta/agentImageGenerationMeta.js'; -import { readMcpServerControllingSetting, readMcpServerDisplayName, readMcpServerSource, readMcpServerSourcePlugin } from '../../common/meta/mcpCustomizationMeta.js'; +import { readMcpServerConnectorName, 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 { agentModelConfigurationReportKey, readAgentModelConfigurationReport } from '../../common/agentModelConfiguration.js'; @@ -118,7 +118,7 @@ import { SAVE_SANDBOX_PATH } from '../../common/sandboxPathGrant.js'; import { AgentSandboxEnabledValue } from '../../../sandbox/common/settings.js'; import { createNoopGitService, createSessionDataService, createZeroDiffComputeService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; import { OtelData } from '../../common/otlp/otlpLogEmitter.js'; -import { type IAgentServerToolDefinition, IAgentServerToolHost } from '../../common/agentServerTools.js'; +import { type IAgentServerToolDefinition, IAgentServerToolHost, type IServerToolConfirmationDisplay } from '../../common/agentServerTools.js'; import { ArtifactServerToolName, SessionServerToolName } from '../../common/serverToolNames.js'; import { readSessionArtifacts } from '../../common/sessionArtifacts.js'; import { AgentServerToolHost } from '../../node/shared/agentServerToolHost.js'; @@ -260,6 +260,7 @@ class MockCopilotSession { }; readonly connectorReconcileCalls: Parameters[0][] = []; onConnectorReconcile: (() => Promise) | undefined; + onConnectorCapabilities: (() => Promise) | undefined; onMcpAuthenticationStateChanged: (() => void) | undefined; mcpAuthenticationStateChangedError: Error | undefined; readonly samplingResponses: Parameters[0][] = []; @@ -727,7 +728,10 @@ class MockCopilotSession { }, }, connectors: { - getCapabilities: async () => this.connectorCapabilities, + getCapabilities: async () => { + await this.onConnectorCapabilities?.(); + return this.connectorCapabilities; + }, getAccount: async () => this.connectorAccount, getStatus: async () => this.connectorStatus, reconcile: async (request: Parameters[0]) => { @@ -1294,6 +1298,8 @@ async function createAgentSession(disposables: DisposableStore, options?: { resolvedAgentName?: string; initializeEnablementSession?: (session: string) => Promise; beforeLaunch?: (rootValues: Record) => void; + /** Delivers launch-time SDK events before the session wrapper is attached. */ + duringLaunch?: (runtime: ICopilotSessionRuntime) => void; /** BYOK providers and models the SDK session launches with. */ launchByokConfig?: ICopilotByokSessionConfig; /** BYOK providers and models the launcher currently resolves. */ @@ -1412,6 +1418,7 @@ async function createAgentSession(disposables: DisposableStore, options?: { if (options?.captureRuntime) { options.captureRuntime.current = runtime; } + options?.duringLaunch?.(runtime); return new CopilotSessionWrapper(mockSession as unknown as CopilotSession, options?.canvasRuntimeEnabled ?? true, options?.launchByokConfig ?? {}, imageGenerationEnabled, logService); }, resolveByokSessionConfig: async () => options?.resolveByokSessionConfig?.() ?? {}, @@ -24366,6 +24373,8 @@ Use the attached image as context. canRequireConfirmation(toolName: string): boolean { return this.confirmationToolNames.has(toolName); } requiresConfirmation(_sessionUri: string, toolName: string): boolean { return this.sessionConfirmationToolNames.has(toolName); } + confirmationDisplay: IServerToolConfirmationDisplay | undefined; + getConfirmationDisplay(): IServerToolConfirmationDisplay | undefined { return this.confirmationDisplay; } executeTool(sessionUri: string, toolName: string, rawArgs: unknown): string { this.executions.push({ sessionUri, toolName, rawArgs }); @@ -24735,6 +24744,35 @@ Use the attached image as context. assert.deepStrictEqual(await permission, { kind: 'reject', feedback: 'The user denied permission.' }); }); + + test('shows the confirmation the host builds for a server tool', async () => { + const serverToolHost = new FakeServerToolHost(); + const toolName = serverToolHost.toolNames[0]; + serverToolHost.confirmationToolNames.add(toolName); + serverToolHost.sessionConfirmationToolNames.add(toolName); + serverToolHost.confirmationDisplay = { confirmationTitle: 'Host Title?', confirmationMessage: { markdown: 'Host [link]()' }, hideConfirmationInput: true }; + const { session, runtime, waitForSignal } = await createAgentSession(disposables, { serverToolHost }); + + const permission = runtime.handlePermissionRequest({ + kind: 'custom-tool', + toolCallId: 'tc-host-display', + toolName, + args: { secret: 'raw' }, + }); + const signal = await waitForSignal(candidate => candidate.kind === 'pending_confirmation' && candidate.state.toolCallId === 'tc-host-display'); + session.respondToPermissionRequest('tc-host-display', true); + await permission; + + assert.deepStrictEqual(signal.kind === 'pending_confirmation' ? { + title: signal.state.confirmationTitle, + message: signal.state.invocationMessage, + toolInput: signal.state.toolInput, + } : undefined, { + title: 'Host Title?', + message: { markdown: 'Host [link]()' }, + toolInput: undefined, + }); + }); }); // ---- Plan mode ---------------------------------------------------------- @@ -25366,6 +25404,53 @@ Use the attached image as context. ]); }); + test('counts an MCP restart during session open once', async () => { + const telemetryService = new CapturingTelemetryService(); + const status = (status: SessionEventPayload<'session.mcp_server_status_changed'>['data']['status']): SessionEvent => ({ + type: 'session.mcp_server_status_changed', id: `status-${status}`, timestamp: new Date().toISOString(), parentId: null, ephemeral: true, + data: { serverName: 'private-server', status }, + }); + const { mockSession } = await createAgentSession(disposables, { + telemetryService, + duringLaunch: runtime => { + for (const event of [status('pending'), status('connected'), status('not_configured'), status('pending')]) { + runtime.onSessionEvent?.(event); + } + }, + }); + mockSession.fire('session.mcp_server_status_changed', { serverName: 'private-server', status: 'connected' }); + + assert.deepStrictEqual(runtimeRestartEvents(telemetryService), [{ kind: 'mcpServer', cause: 'runtime', firstTurn: true, phase: 'idle' }]); + }); + + test('reports an MCP restart while resuming a chat with completed history as a later-turn restart', async () => { + const telemetryService = new CapturingTelemetryService(); + const status = (status: SessionEventPayload<'session.mcp_server_status_changed'>['data']['status']): SessionEvent => ({ + type: 'session.mcp_server_status_changed', id: `status-${status}`, timestamp: new Date().toISOString(), parentId: null, ephemeral: true, + data: { serverName: 'private-server', status }, + }); + const { session } = await createAgentSession(disposables, { + telemetryService, + resume: true, + configureMockSession: mock => { + mock.messages = toSessionEvents([ + { type: 'user.message', id: 'request', data: { content: 'hello' } }, + { type: 'assistant.message', data: { messageId: 'reply', content: 'hi' } }, + { type: 'session.idle', data: {} }, + ]); + }, + duringLaunch: runtime => { + for (const event of [status('pending'), status('connected'), status('not_configured'), status('pending')]) { + runtime.onSessionEvent?.(event); + } + }, + }); + assert.strictEqual(await session.completedTurnHistory, true); + await timeout(0); + + assert.deepStrictEqual(runtimeRestartEvents(telemetryService), [{ kind: 'mcpServer', cause: 'runtime', firstTurn: false, phase: 'idle' }]); + }); + test('reports MCP restarts after a completed turn as later-turn restarts', async () => { const telemetryService = new CapturingTelemetryService(); const { session, mockSession } = await createAgentSession(disposables, { telemetryService }); @@ -27142,6 +27227,141 @@ Use the attached image as context. }); }); + test('does not resolve a Connector upscope request with an MCP token and releases it after a forced reconcile', async () => { + const serverUrl = 'https://api.github.com/copilot-connectors/api/v1/connectors/workiqmcp/mcp'; + const { session, mockSession, runtime, waitForSignal } = await createAgentSession(disposables, { + rootValues, + configureMockSession: mock => { + mock.connectorStatus = { + ...mock.connectorStatus, + catalog: { revision: 1, refreshedAtMs: 1, connectors: [{ name: 'workiqmcp', displayName: 'Work IQ', status: 'connected', runtimeServerIds: ['connector-workiq'] }] }, + runtimeServers: [{ connectorName: 'workiqmcp', runtimeServerId: 'connector-workiq', status: 'needs_auth' }], + }; + mock.mcpListResult = { servers: [{ name: 'connector-workiq', status: 'needs-auth', source: 'managed', url: serverUrl }] }; + }, + }); + session.markConnectorConfigurationChanged(); + await session.refreshConnectors(); + + let settled = false; + const auth = runtime.handleMcpAuthRequest({ + requestId: 'workiq-auth', + serverName: 'connector-workiq', + serverUrl, + reason: 'upscope', + wwwAuthenticateParams: { scope: 'write:plugin_gateway_connections', error: 'insufficient_scope' }, + }, { sessionId: mockSession.sessionId }); + void auth.then(() => { settled = true; }); + await waitForSignal(signal => isAction(signal, ActionType.SessionCustomizationUpdated)); + const resolved = await session.resolveMcpAuthentication({ resource: serverUrl, scopes: ['write:plugin_gateway_connections'], token: 'narrow-token' }); + const afterToken = { resolved, settled, state: session.topLevelMcpCustomizations()[0].state.kind }; + + const statesDuringReconcile: McpServerStatus[] = []; + mockSession.onConnectorReconcile = async () => { + statesDuringReconcile.push(session.topLevelMcpCustomizations()[0].state.kind); + mockSession.mcpListResult = { servers: [{ name: 'connector-workiq', status: 'pending', source: 'managed', url: serverUrl }] }; + }; + session.markConnectorConfigurationChanged('workiqmcp'); + await session.refreshConnectors(); + + assert.deepStrictEqual({ + afterToken, + statesDuringReconcile, + auth: await auth, + connectorName: readMcpServerConnectorName(session.topLevelMcpCustomizations()[0]), + calls: mockSession.connectorReconcileCalls.map(call => call.forceConnectorName), + }, { + afterToken: { resolved: false, settled: false, state: McpServerStatus.AuthRequired }, + statesDuringReconcile: [McpServerStatus.Starting], + auth: { kind: 'cancelled' }, + connectorName: 'workiqmcp', + calls: [undefined, 'workiqmcp'], + }); + }); + + test('restores the Connector authentication challenge when a forced refresh is cancelled before reconciliation', async () => { + const serverUrl = 'https://api.github.com/copilot-connectors/api/v1/connectors/workiqmcp/mcp'; + const { session, mockSession, runtime, waitForSignal } = await createAgentSession(disposables, { + rootValues, + configureMockSession: mock => { + mock.connectorStatus = { + ...mock.connectorStatus, + runtimeServers: [{ connectorName: 'workiqmcp', runtimeServerId: 'connector-workiq', status: 'needs_auth' }], + }; + mock.mcpListResult = { servers: [{ name: 'connector-workiq', status: 'needs-auth', source: 'managed', url: serverUrl }] }; + }, + }); + session.markConnectorConfigurationChanged(); + await session.refreshConnectors(); + + let settled = false; + const auth = runtime.handleMcpAuthRequest({ + requestId: 'workiq-auth', + serverName: 'connector-workiq', + serverUrl, + reason: 'upscope', + wwwAuthenticateParams: { scope: 'write:plugin_gateway_connections', error: 'insufficient_scope' }, + }, { sessionId: mockSession.sessionId }); + void auth.then(() => { settled = true; }); + await waitForSignal(signal => isAction(signal, ActionType.SessionCustomizationUpdated)); + const challenge = session.topLevelMcpCustomizations()[0].state; + + const capabilitiesRequested = new DeferredPromise(); + mockSession.onConnectorCapabilities = () => { + capabilitiesRequested.complete(); + return new Promise(() => { /* the capability RPC never answers */ }); + }; + session.markConnectorConfigurationChanged('workiqmcp'); + const refresh = session.refreshConnectors(); + await capabilitiesRequested.p; + const duringRefresh = session.topLevelMcpCustomizations()[0].state.kind; + session.cancelConnectorRefresh(); + await refresh; + + assert.deepStrictEqual({ + duringRefresh, + restored: session.topLevelMcpCustomizations()[0].state, + settled, + pending: session.hasPendingConnectorRefresh, + forcedReconciles: mockSession.connectorReconcileCalls.filter(call => call.forceConnectorName !== undefined).length, + }, { + duringRefresh: McpServerStatus.Starting, + restored: challenge, + settled: false, + pending: true, + forcedReconciles: 0, + }); + session.cancelMcpServerAuthentication(session.topLevelMcpCustomizations()[0].id); + assert.deepStrictEqual(await auth, { kind: 'cancelled' }); + }); + + test('reports a Connector authorization requirement for a Connector-owned server that needs authentication', async () => { + const serverUrl = 'https://api.github.com/copilot-connectors/api/v1/connectors/kusto/mcp'; + const { session } = await createAgentSession(disposables, { + rootValues, + configureMockSession: mock => { + mock.connectorStatus = { + ...mock.connectorStatus, + authorizationRequirement: { accountId: 'session-account', scope: 'write_plugin_gateway_connections' }, + runtimeServers: [{ connectorName: 'kusto', runtimeServerId: 'connector-kusto', status: 'needs_auth' }], + }; + mock.mcpListResult = { servers: [{ name: 'connector-kusto', status: 'needs-auth', source: 'managed', url: serverUrl }] }; + }, + }); + session.markConnectorConfigurationChanged(); + await session.refreshConnectors(); + + assert.deepStrictEqual(session.topLevelMcpCustomizations().map(server => ({ name: server.name, state: server.state })), [{ + name: 'connector-kusto', + state: { + kind: McpServerStatus.AuthRequired, + reason: McpAuthRequiredReason.InsufficientScope, + resource: { resource: serverUrl, resource_name: 'kusto', scopes_supported: ['write:plugin_gateway_connections'] }, + requiredScopes: ['write:plugin_gateway_connections'], + }, + }]); + }); + test('coalesces queued requests and retains concurrent changes, including distinct named reconnects', async () => { const { session, mockSession } = await createAgentSession(disposables, { rootValues }); const started = new DeferredPromise(); @@ -27427,33 +27647,42 @@ Use the attached image as context. } }); - test('republishes pending connector authentication when display names become available', async () => { + test('republishes pending connector authentication when Connector ownership becomes available, without accepting an MCP token', async () => { const runtimeServerId = 'github-copilot-connector-94d26095770df60673dd'; const resource = 'https://api.github.com'; const { session, runtime, waitForSignal } = await createAgentSession(disposables); + let settled = false; const authPromise = runtime.handleMcpAuthRequest({ requestId: 'auth-connector', serverName: runtimeServerId, serverUrl: resource, reason: 'upscope', }, { sessionId: 'test-session-1' }); + void authPromise.then(() => { settled = true; }); await waitForSignal(signal => isAction(signal, ActionType.SessionCustomizationUpdated)); const beforeDisplayName = readMcpServerDisplayName(session.topLevelMcpCustomizations()[0]); - runtime.setMcpServerDisplayNames(new Map([[runtimeServerId, 'GitHub']])); + runtime.setCopilotConnectorServers({ servers: new Map([[runtimeServerId, { connectorName: 'github', displayName: 'GitHub' }]]), authorizationRequired: false }); const [server] = session.topLevelMcpCustomizations(); - await session.resolveMcpAuthentication({ resource, scopes: [], token: 'connector-token' }); + const resolved = await session.resolveMcpAuthentication({ resource, scopes: [], token: 'connector-token' }); + session.cancelMcpServerAuthentication(server.id); assert.deepStrictEqual({ beforeDisplayName, name: server.name, displayName: readMcpServerDisplayName(server), + connectorName: readMcpServerConnectorName(server), + resolved, + settledByToken: settled, result: await authPromise, }, { beforeDisplayName: undefined, name: runtimeServerId, displayName: 'GitHub', - result: { kind: 'token', accessToken: 'connector-token' }, + connectorName: 'github', + resolved: false, + settledByToken: false, + result: { kind: 'cancelled' }, }); }); diff --git a/src/vs/platform/agentHost/test/node/copilotCustomizationInstallations.test.ts b/src/vs/platform/agentHost/test/node/copilotCustomizationInstallations.test.ts index 9ddbde89bfcce1..dbc9a1796316d0 100644 --- a/src/vs/platform/agentHost/test/node/copilotCustomizationInstallations.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotCustomizationInstallations.test.ts @@ -398,7 +398,7 @@ suite('CopilotCustomizationInstallations', () => { assert.deepStrictEqual(requests, [{ name: 'demo', config: { - type: 'local', + type: 'stdio', command: 'node', args: ['server.js'], tools: ['*'], @@ -443,6 +443,7 @@ suite('CopilotCustomizationInstallations', () => { mediaType: 'application/mcp-server+json' as const, installability: 'installable' as const, displayName: 'Installable MCP', + connectorIds: ['githubcopilotfigmamcp'], source: { kind: 'url' as const, url: 'https://example.test/installable-mcp' }, provenance: { authority: 'agentfinder.github.com', observedAt, mediaType: 'application/mcp-server+json' as const }, }, @@ -497,12 +498,12 @@ suite('CopilotCustomizationInstallations', () => { const result = await service.search({ query: 'demo', limit: 10 }); assert.deepStrictEqual(result.kind === 'page' ? { - items: result.items.map(item => ({ kind: item.kind, displayName: item.displayName, itemUrl: item.itemUrl, installable: item.installable })), + items: result.items.map(item => ({ kind: item.kind, displayName: item.displayName, itemUrl: item.itemUrl, connectorIds: item.connectorIds, installable: item.installable })), hasNextCursor: typeof result.nextCursor === 'string', } : result, { items: [ - { kind: 'skill', displayName: 'Installable Skill', itemUrl: 'https://example.test/installable-skill', installable: true }, - { kind: 'mcp', displayName: 'Installable MCP', itemUrl: 'https://example.test/installable-mcp', installable: true }, + { kind: 'skill', displayName: 'Installable Skill', itemUrl: 'https://example.test/installable-skill', connectorIds: undefined, installable: true }, + { kind: 'mcp', displayName: 'Installable MCP', itemUrl: 'https://example.test/installable-mcp', connectorIds: ['githubcopilotfigmamcp'], installable: true }, ], hasNextCursor: true, }); diff --git a/src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts b/src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts index 17b710428b7b02..9b609e36f81488 100644 --- a/src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts @@ -16,7 +16,7 @@ import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesy import { NullLogService } from '../../../log/common/log.js'; import { IMcpServerConfiguration, McpServerType } from '../../../mcp/common/mcpPlatformTypes.js'; import { toCopilotMcpServerConfiguration } from '../../../mcp/common/mcpCopilotConfiguration.js'; -import { toSdkInstructionDirectories, toSdkMcpServers, toSdkCustomAgents, toSdkSessionCustomAgents, toSdkSkillDirectories, parsedPluginsEqual, toSdkHooks, type IPluginAgentsForSdk } from '../../node/copilot/copilotPluginConverters.js'; +import { toSdkInstructionDirectories, toSdkMcpServers, toSdkCustomAgents, toSdkSessionCustomAgents, toSdkSkillDirectories, findRelocatedAgentName, parsedPluginsEqual, toSdkHooks, type IPluginAgentsForSdk } from '../../node/copilot/copilotPluginConverters.js'; import { PluginFormat, type IMcpServerDefinition, type INamedPluginResource, type IParsedHookGroup, type IParsedPlugin, type IParsedSkill } from '../../../agentPlugins/common/pluginParsers.js'; import { CustomizationType, McpServerStatus, type HookCustomization, type McpServerCustomization, type SkillCustomization } from '../../common/state/protocol/state.js'; @@ -76,7 +76,7 @@ suite('copilotPluginConverters', () => { const result = toSdkMcpServers(defs); assert.deepStrictEqual(result, { 'test-server': { - type: 'local', + type: 'stdio', command: 'node', args: ['server.js', '--port', '3000'], tools: ['*'], @@ -170,7 +170,7 @@ suite('copilotPluginConverters', () => { }]; const result = toSdkMcpServers(defs); - assert.strictEqual(result['minimal'].type, 'local'); + assert.strictEqual(result['minimal'].type, 'stdio'); assert.deepStrictEqual((result['minimal'] as { args?: string[] }).args, []); assert.strictEqual(Object.hasOwn(result['minimal'], 'env'), false); assert.strictEqual(Object.hasOwn(result['minimal'], 'cwd'), false); @@ -285,6 +285,100 @@ suite('copilotPluginConverters', () => { }]); }); + test('preserves agent-specific frontmatter hooks without merging between agents', async () => { + const firstAgentUri = URI.from({ scheme: Schemas.inMemory, path: '/agents/first.md' }); + const secondAgentUri = URI.from({ scheme: Schemas.inMemory, path: '/agents/second.md' }); + await fileService.writeFile(firstAgentUri, VSBuffer.fromString([ + '---', + 'name: first', + 'hooks:', + ' SessionStart:', + ' - type: command', + ' command: echo first', + ' PreToolUse:', + ' - type: command', + ' command: deny first', + ' timeout: 5', + '---', + 'First agent.', + ].join('\n'))); + await fileService.writeFile(secondAgentUri, VSBuffer.fromString([ + '---', + 'name: second', + 'hooks:', + ' PreToolUse:', + ' - type: command', + ' command: deny second', + '---', + 'Second agent.', + ].join('\n'))); + + const result = await toSdkCustomAgents([ + { uri: firstAgentUri, name: 'first' }, + { uri: secondAgentUri, name: 'second' }, + ], fileService); + + assert.deepStrictEqual(result, [ + { + name: 'first', + tools: null, + hooks: { + SessionStart: [{ type: 'command', command: 'echo first' }], + PreToolUse: [{ type: 'command', command: 'deny first', timeout: 5 }], + }, + prompt: 'First agent.', + }, + { + name: 'second', + tools: null, + hooks: { + PreToolUse: [{ type: 'command', command: 'deny second' }], + }, + prompt: 'Second agent.', + }, + ]); + }); + + test('preserves valid hook commands around malformed entries', async () => { + const agentUri = URI.from({ scheme: Schemas.inMemory, path: '/agents/mixed.md' }); + await fileService.writeFile(agentUri, VSBuffer.fromString([ + '---', + 'name: mixed', + 'hooks:', + ' SessionStart:', + ' - type: command', + ' command: echo started', + ' PreToolUse:', + ' - invalid', + ' - type: command', + ' command: deny first', + ' - [invalid]', + ' - type: command', + ' command: deny second', + ' - null', + ' SessionEnd:', + ' - invalid', + ' - [invalid]', + '---', + 'Mixed hooks.', + ].join('\n'))); + + const result = await toSdkCustomAgents([{ uri: agentUri, name: 'mixed' }], fileService); + + assert.deepStrictEqual(result, [{ + name: 'mixed', + tools: null, + hooks: { + SessionStart: [{ type: 'command', command: 'echo started' }], + PreToolUse: [ + { type: 'command', command: 'deny first' }, + { type: 'command', command: 'deny second' }, + ], + }, + prompt: 'Mixed hooks.', + }]); + }); + test('parses supported reasoning-effort values from frontmatter', async () => { const reasoningEfforts = ['low', 'medium', 'high', 'xhigh', 'max'] as const; const agents: INamedPluginResource[] = []; @@ -363,25 +457,46 @@ suite('copilotPluginConverters', () => { }]); }); - test('infer defaults to false when disable-model-invocation is set', async () => { - const agentUri = URI.from({ scheme: Schemas.inMemory, path: '/agents/no-invoke.md' }); - await fileService.writeFile(agentUri, VSBuffer.fromString([ + test('forwards model invocation restrictions', async () => { + const disabledUri = URI.from({ scheme: Schemas.inMemory, path: '/agents/no-invoke.md' }); + const legacyUri = URI.from({ scheme: Schemas.inMemory, path: '/agents/legacy-no-invoke.md' }); + await fileService.writeFile(disabledUri, VSBuffer.fromString([ '---', 'name: no-invoke', 'disable-model-invocation: true', '---', 'Body.', ].join('\n'))); + await fileService.writeFile(legacyUri, VSBuffer.fromString([ + '---', + 'name: legacy-no-invoke', + 'infer: false', + '---', + 'Legacy body.', + ].join('\n'))); - const agents: INamedPluginResource[] = [{ uri: agentUri, name: 'no-invoke' }]; + const agents: INamedPluginResource[] = [ + { uri: disabledUri, name: 'no-invoke' }, + { uri: legacyUri, name: 'legacy-no-invoke' }, + ]; const result = await toSdkCustomAgents(agents, fileService); - assert.deepStrictEqual(result, [{ - name: 'no-invoke', - tools: null, - infer: false, - prompt: 'Body.', - }]); + assert.deepStrictEqual(result, [ + { + name: 'no-invoke', + tools: null, + infer: false, + disableModelInvocation: true, + prompt: 'Body.', + }, + { + name: 'legacy-no-invoke', + tools: null, + infer: false, + disableModelInvocation: true, + prompt: 'Legacy body.', + }, + ]); }); test('omits skills and infer when frontmatter does not specify them', async () => { @@ -470,11 +585,19 @@ suite('copilotPluginConverters', () => { assert.deepStrictEqual(result, [{ name: 'helper', tools: null, prompt: 'Loose agent' }]); }); - test('includes all agents from file-dir plugins', async () => { + test('includes plugin agents and preserves their hooks', async () => { const parentUri = URI.from({ scheme: Schemas.inMemory, path: '/plugin/parent.md' }); const siblingUri = URI.from({ scheme: Schemas.inMemory, path: '/plugin/sibling.md' }); await Promise.all([ - fileService.writeFile(parentUri, VSBuffer.fromString('Parent agent')), + fileService.writeFile(parentUri, VSBuffer.fromString([ + '---', + 'hooks:', + ' PreToolUse:', + ' - type: command', + ' command: deny parent', + '---', + 'Parent agent', + ].join('\n'))), fileService.writeFile(siblingUri, VSBuffer.fromString('Sibling agent')), ]); @@ -487,7 +610,12 @@ suite('copilotPluginConverters', () => { const result = await toSdkSessionCustomAgents(plugins, fileService); assert.deepStrictEqual(result, [ - { name: 'parent', tools: null, prompt: 'Parent agent' }, + { + name: 'parent', + tools: null, + hooks: { PreToolUse: [{ type: 'command', command: 'deny parent' }] }, + prompt: 'Parent agent', + }, { name: 'sibling', tools: null, prompt: 'Sibling agent' }, ]); }); @@ -768,6 +896,28 @@ suite('copilotPluginConverters', () => { }, { plugin: true, standalone: true }); }); + test('compares absolute roots when a plugin has no content nonce', () => { + const a = relocatedPlugin('/cache/old/revision'); + const b = relocatedPlugin('/cache/new/revision'); + assert.deepStrictEqual([ + parsedPluginsEqual([{ ...a, nonce: undefined }], [{ ...b, nonce: undefined }]), + parsedPluginsEqual([{ ...a, nonce: '' }], [{ ...b, nonce: '' }]), + parsedPluginsEqual([{ ...a, nonce: undefined }], [{ ...a, nonce: undefined }]), + ], [false, false, true]); + }); + + test('resolves a published agent URI to the applied agent name only across an equivalent relocation', () => { + const applied = relocatedPlugin('/cache/old/revision'); + const published = relocatedPlugin('/cache/new/revision'); + const agentUri = published.agents[0].uri.toString(); + assert.deepStrictEqual([ + findRelocatedAgentName([applied], [published], agentUri), + findRelocatedAgentName([applied], [{ ...published, nonce: 'changed-resource-body' }], agentUri), + findRelocatedAgentName([applied], [published], 'file:///unknown.agent.md'), + findRelocatedAgentName([applied], [{ ...makePlugin(), pluginDir: URI.file('/workspace') }, published], agentUri), + ], [applied.agents[0].name, undefined, undefined, applied.agents[0].name]); + }); + test('preserves content changes when plugin roots differ', () => { const a = relocatedPlugin('/cache/old/revision'); const b = relocatedPlugin('/cache/new/revision'); diff --git a/src/vs/platform/agentHost/test/node/copilotProjectExtensionLaunchPolicy.test.ts b/src/vs/platform/agentHost/test/node/copilotProjectExtensionLaunchPolicy.test.ts new file mode 100644 index 00000000000000..aaf73b2e0ad9ff --- /dev/null +++ b/src/vs/platform/agentHost/test/node/copilotProjectExtensionLaunchPolicy.test.ts @@ -0,0 +1,232 @@ +/*--------------------------------------------------------------------------------------------- + * 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 fs from 'fs/promises'; +import * as os from 'os'; +import { DeferredPromise } from '../../../../base/common/async.js'; +import { join } from '../../../../base/common/path.js'; +import { isEqual } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { AgentHostWorkspaceTrustConfigKey } from '../../common/agentHostSchema.js'; +import { AgentConfigurationService } from '../../node/agentConfigurationService.js'; +import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; +import { CopilotProjectExtensionLaunchPolicy, parseCopilotProjectExtensionTrustGrant, serializeCopilotProjectExtensionTrustGrant } from '../../node/copilot/copilotProjectExtensionLaunchPolicy.js'; + +suite('CopilotProjectExtensionLaunchPolicy', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function createPolicy(realpath: (path: string) => Promise = fs.realpath) { + const logService = new NullLogService(); + const stateManager = disposables.add(new AgentHostStateManager(logService)); + const configurationService = disposables.add(new AgentConfigurationService(stateManager, logService)); + return { + configurationService, + policy: new CopilotProjectExtensionLaunchPolicy(configurationService, logService, realpath), + }; + } + + test('gates project launches by admission and workspace trust', async () => { + const root = await fs.mkdtemp(`${os.tmpdir()}/copilot-extension-trust-`); + const workspace = join(root, 'workspace'); + const trustedAlias = join(root, 'trusted-alias'); + const extensionDirectory = join(workspace, '.github', 'extensions', 'preview'); + const modulePath = join(extensionDirectory, 'extension.mjs'); + await fs.mkdir(extensionDirectory, { recursive: true }); + await fs.writeFile(modulePath, ''); + await fs.symlink(workspace, trustedAlias, process.platform === 'win32' ? 'junction' : 'dir'); + const { configurationService, policy } = createPolicy(); + try { + const admission = disposables.add(await policy.acquireAdmission([URI.file(workspace)], undefined)); + const missingTrustConfig = await policy.resolveLaunch(modulePath, 'project', '/copilot-sdk/extension-bootstrap.mjs'); + configurationService.updateRootConfig({ + [AgentHostWorkspaceTrustConfigKey]: { enabled: true, trustedUris: [] }, + }); + const untrusted = await policy.resolveLaunch(modulePath, 'project', '/copilot-sdk/extension-bootstrap.mjs'); + configurationService.updateRootConfig({ + [AgentHostWorkspaceTrustConfigKey]: { enabled: true, trustedUris: [], connectionAuthorityTrusted: true }, + }); + const authorityTrusted = await policy.resolveLaunch(modulePath, 'project', '/copilot-sdk/extension-bootstrap.mjs'); + configurationService.updateRootConfig({ + [AgentHostWorkspaceTrustConfigKey]: { enabled: true, trustedUris: [URI.file(trustedAlias).toString()] }, + }); + const trusted = await policy.resolveLaunch(modulePath, 'project', '/copilot-sdk/extension-bootstrap.mjs'); + configurationService.updateRootConfig({ + [AgentHostWorkspaceTrustConfigKey]: { enabled: false, trustedUris: [] }, + }); + const trustDisabled = await policy.resolveLaunch(modulePath, 'project', '/copilot-sdk/extension-bootstrap.mjs'); + admission.dispose(); + const noAdmission = await policy.resolveLaunch(modulePath, 'project', '/copilot-sdk/extension-bootstrap.mjs'); + + assert.deepStrictEqual({ + missingTrustConfig: !!missingTrustConfig.launch, + untrusted: !!untrusted.launch, + authorityTrusted: !!authorityTrusted.launch, + trusted: !!trusted.launch, + trustDisabled: !!trustDisabled.launch, + noAdmission: !!noAdmission.launch, + }, { + missingTrustConfig: false, + untrusted: false, + authorityTrusted: true, + trusted: true, + trustDisabled: true, + noAdmission: false, + }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + test('rejects symlink escapes and trust revocation during canonicalization', async () => { + const root = await fs.mkdtemp(`${os.tmpdir()}/copilot-extension-race-`); + const workspace = join(root, 'workspace'); + const untrustedDirectory = join(root, 'untrusted'); + const extensionDirectory = join(workspace, '.github', 'extensions', 'preview'); + const escapedDirectory = join(workspace, '.github', 'extensions', 'escaped'); + const modulePath = join(extensionDirectory, 'extension.mjs'); + const escapedModulePath = join(escapedDirectory, 'extension.mjs'); + await fs.mkdir(extensionDirectory, { recursive: true }); + await fs.mkdir(untrustedDirectory, { recursive: true }); + await fs.writeFile(modulePath, ''); + await fs.writeFile(join(untrustedDirectory, 'extension.mjs'), ''); + await fs.symlink(untrustedDirectory, escapedDirectory, process.platform === 'win32' ? 'junction' : 'dir'); + const realpathStarted = new DeferredPromise(); + const releaseRealpath = new DeferredPromise(); + let delayModuleResolution = false; + const { configurationService, policy } = createPolicy(async path => { + if (delayModuleResolution && isEqual(URI.file(path), URI.file(modulePath))) { + realpathStarted.complete(); + await releaseRealpath.p; + } + return fs.realpath(path); + }); + try { + disposables.add(await policy.acquireAdmission([URI.file(workspace), URI.file(untrustedDirectory)], undefined)); + configurationService.updateRootConfig({ + [AgentHostWorkspaceTrustConfigKey]: { enabled: true, trustedUris: [URI.file(workspace).toString()] }, + }); + const escaped = await policy.resolveLaunch(escapedModulePath, 'project', '/copilot-sdk/extension-bootstrap.mjs'); + delayModuleResolution = true; + const revokedPromise = policy.resolveLaunch(modulePath, 'project', '/copilot-sdk/extension-bootstrap.mjs'); + await realpathStarted.p; + configurationService.updateRootConfig({ + [AgentHostWorkspaceTrustConfigKey]: { enabled: true, trustedUris: [] }, + }); + releaseRealpath.complete(); + const revoked = await revokedPromise; + + assert.deepStrictEqual({ + escaped: !!escaped.launch, + revoked: !!revoked.launch, + }, { + escaped: false, + revoked: false, + }); + } finally { + releaseRealpath.complete(); + await fs.rm(root, { recursive: true, force: true }); + } + }); + + test('validates, rechecks, and binds inherited worktree trust', async () => { + const root = await fs.mkdtemp(`${os.tmpdir()}/copilot-worktree-trust-`); + const repository = join(root, 'repository'); + const worktree = join(root, 'repository.worktrees', 'session'); + const swappedWorktree = join(root, 'repository.worktrees', 'swapped'); + const siblingWorktree = join(root, 'repository.worktrees', 'sibling'); + const ordinaryFolder = join(root, 'repository.worktrees', 'ordinary'); + const extensionDirectory = join(worktree, '.github', 'extensions', 'preview'); + const swappedExtensionDirectory = join(swappedWorktree, '.github', 'extensions', 'preview'); + const siblingExtensionDirectory = join(siblingWorktree, '.github', 'extensions', 'preview'); + const modulePath = join(extensionDirectory, 'extension.mjs'); + const swappedModulePath = join(swappedExtensionDirectory, 'extension.mjs'); + await fs.mkdir(repository, { recursive: true }); + await fs.mkdir(extensionDirectory, { recursive: true }); + await fs.mkdir(swappedExtensionDirectory, { recursive: true }); + await fs.mkdir(siblingExtensionDirectory, { recursive: true }); + await fs.mkdir(ordinaryFolder, { recursive: true }); + await fs.writeFile(modulePath, ''); + await fs.writeFile(swappedModulePath, ''); + await fs.writeFile(join(siblingExtensionDirectory, 'extension.mjs'), ''); + const { configurationService, policy } = createPolicy(); + try { + configurationService.updateRootConfig({ + [AgentHostWorkspaceTrustConfigKey]: { enabled: true, trustedUris: [URI.file(repository).toString()] }, + }); + const grant = await policy.resolveTrustGrant(URI.file(worktree), { + kind: 'knownRepository', + repositoryRoot: URI.file(repository), + }); + const derivedGrant = await policy.resolveTrustGrant(URI.file(worktree), { + kind: 'hostCreatedWorktree', + }); + const invalidGrant = await policy.resolveTrustGrant(URI.file(ordinaryFolder), { + kind: 'knownRepository', + repositoryRoot: URI.file(ordinaryFolder), + }); + const swappedGrant = await policy.resolveTrustGrant(URI.file(swappedWorktree), { + kind: 'knownRepository', + repositoryRoot: URI.file(repository), + }); + await fs.rm(swappedWorktree, { recursive: true, force: true }); + await fs.symlink(siblingWorktree, swappedWorktree, process.platform === 'win32' ? 'junction' : 'dir'); + const swappedAdmission = disposables.add(await policy.acquireAdmission([URI.file(swappedWorktree)], swappedGrant)); + const swappedAfterValidation = await policy.resolveLaunch(swappedModulePath, 'project', '/copilot-sdk/extension-bootstrap.mjs'); + swappedAdmission.dispose(); + const movedGrant = await policy.resolvePersistedTrustGrant(URI.file(ordinaryFolder), grant); + const roundTrip = parseCopilotProjectExtensionTrustGrant(serializeCopilotProjectExtensionTrustGrant(grant)); + const worktreeAdmission = disposables.add(await policy.acquireAdmission([URI.file(worktree)], grant)); + const inheritedWorktree = await policy.resolveLaunch(modulePath, 'project', '/copilot-sdk/extension-bootstrap.mjs'); + configurationService.updateRootConfig({ + [AgentHostWorkspaceTrustConfigKey]: { enabled: true, trustedUris: [] }, + }); + const inheritedAfterRevocation = await policy.resolveLaunch(modulePath, 'project', '/copilot-sdk/extension-bootstrap.mjs'); + worktreeAdmission.dispose(); + + assert.deepStrictEqual({ + grant: grant && { + worktreeDirectory: grant.worktreeDirectory.toString(), + repositoryRoot: grant.repositoryRoot.toString(), + }, + derivedGrant: derivedGrant && { + worktreeDirectory: derivedGrant.worktreeDirectory.toString(), + repositoryRoot: derivedGrant.repositoryRoot.toString(), + }, + roundTrip: roundTrip && { + worktreeDirectory: roundTrip.worktreeDirectory.toString(), + repositoryRoot: roundTrip.repositoryRoot.toString(), + }, + invalidGrant, + swappedAfterValidation: !!swappedAfterValidation.launch, + movedGrant, + inheritedWorktree: !!inheritedWorktree.launch, + inheritedAfterRevocation: !!inheritedAfterRevocation.launch, + }, { + grant: { + worktreeDirectory: URI.file(await fs.realpath(worktree)).toString(), + repositoryRoot: URI.file(await fs.realpath(repository)).toString(), + }, + derivedGrant: { + worktreeDirectory: URI.file(await fs.realpath(worktree)).toString(), + repositoryRoot: URI.file(await fs.realpath(repository)).toString(), + }, + roundTrip: { + worktreeDirectory: URI.file(await fs.realpath(worktree)).toString(), + repositoryRoot: URI.file(await fs.realpath(repository)).toString(), + }, + invalidGrant: undefined, + swappedAfterValidation: false, + movedGrant: undefined, + inheritedWorktree: true, + inheritedAfterRevocation: false, + }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/src/vs/platform/agentHost/test/node/copilotSdkManagedSettings.integrationTest.ts b/src/vs/platform/agentHost/test/node/copilotSdkManagedSettings.integrationTest.ts index 4a3d93db189514..6a3aa0701ea819 100644 --- a/src/vs/platform/agentHost/test/node/copilotSdkManagedSettings.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/copilotSdkManagedSettings.integrationTest.ts @@ -52,7 +52,7 @@ suite('Copilot SDK - managed settings', function () { test('bundled managedSettings.schema matches the snapshot', async function () { const result = await client.rpc.managedSettings.schema(); assert.ok(result.schema && typeof result.schema === 'object' && !Array.isArray(result.schema)); - const content = JSON.stringify(result, null, '\t') + '\n'; + const content = JSON.stringify(result.schema, null, '\t') + '\n'; const snapshotPath = snapshotPathForTest(this.test!, 'schema', 'json'); if (process.env[AgentHostUpdateAhpSnapshotsEnvVar] === '1') { await mkdir(dirname(snapshotPath), { recursive: true }); diff --git a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts index fc1849864ec5fc..300d6c39c1509d 100644 --- a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts @@ -65,7 +65,7 @@ const testRuntime: ICopilotSessionRuntime = { handleExitPlanModeRequest: async () => { throw new Error('Unexpected exit plan mode request'); }, handleUserInputRequest: async () => { throw new Error('Unexpected user input request'); }, handleElicitationRequest: async () => { throw new Error('Unexpected elicitation request'); }, - setMcpServerDisplayNames: () => { }, + setCopilotConnectorServers: () => { }, handleMcpAuthRequest: async () => { throw new Error('Unexpected MCP auth request'); }, requestUnsandboxedCommandConfirmation: async () => false, handlePreToolUse: async () => { }, @@ -1421,7 +1421,7 @@ suite('CopilotSessionLauncher Connectors', () => { const launched = await launcher.launch(plan, { ...testRuntime, - setMcpServerDisplayNames: displayNames => connectorDisplayNames = new Map(displayNames), + setCopilotConnectorServers: connectorServers => connectorDisplayNames = new Map([...connectorServers.servers].map(([serverName, server]) => [serverName, server.displayName])), }); try { assert.deepStrictEqual({ 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 30983ebe71ee9a..890875b2289244 100644 --- a/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md +++ b/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md @@ -992,6 +992,10 @@ A user can ask Claude to delete a file from the workspace through its shell tool scripts\test-integration.bat --run src\vs\platform\agentHost\test\node\e2e\providers\claudeAgentHostE2E.integrationTest.ts --grep "deletes a workspace file" ``` +### Codex turn-diff shell fixture uses Bash + +The `shell-intercepted patches use the same turn-diff tracking` provider-integration fixture is separately POSIX-only because it sends a Bash heredoc through Codex's shell. Native patch tracking, checkpoint, steering, subagent, and restart coverage in [codexTurnDiff.integrationTest.ts](../providerIntegration/codexTurnDiff.integrationTest.ts) stays enabled on Windows. + ### Codex file creation replay on Windows A user can ask Codex to create a file in the workspace by running a command through its shell tool. On Windows, the turn finishes and the assistant reports the command as run, but the new file is not in the workspace afterwards, so a user who asked for a file would find nothing there. diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-move-session-explains-a-refused-move.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-move-session-explains-a-refused-move.yaml new file mode 100644 index 00000000000000..528b224a78ddf8 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-move-session-explains-a-refused-move.yaml @@ -0,0 +1,48 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Reply exactly "MOVE_REFUSED_READY". + response: + content: MOVE_REFUSED_READY + stopReason: end_turn + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: You must call move_session exactly once with session "agent-host-session://claude/${uuid_0}" and into "agent-host-session://claude/${uuid_1}" so the host can check the move. Do not refuse on your own. After the tool fails, reply exactly "refused". + response: + content: + - type: tool_use + id: toolcall_0 + name: mcp__host__move_session + input: + session: agent-host-session://claude/${uuid_0} + into: agent-host-session://claude/${uuid_1} + stopReason: tool_use + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: You must call move_session exactly once with session "agent-host-session://claude/${uuid_0}" and into "agent-host-session://claude/${uuid_1}" so the host can check the move. Do not refuse on your own. After the tool fails, reply exactly "refused". + - role: assistant + content: + - type: tool_use + name: mcp__host__move_session + input: + session: agent-host-session://claude/${uuid_0} + into: agent-host-session://claude/${uuid_1} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Cannot move: This session has nested sessions; move them out first, or move each nested session on its own.' + response: + content: refused + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-move-session-makes-a-nested-session-its-own-session.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-move-session-makes-a-nested-session-its-own-session.yaml new file mode 100644 index 00000000000000..c8f5bd2b544458 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-move-session-makes-a-nested-session-its-own-session.yaml @@ -0,0 +1,48 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Reply exactly "PEER_READY". + response: + content: PEER_READY + stopReason: end_turn + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Call move_session exactly once with session "agent-host-session://claude/${uuid_0}?chat=${uuid_1}" and relationship "independent", then reply exactly "moved". + response: + content: + - type: tool_use + id: toolcall_0 + name: mcp__host__move_session + input: + session: agent-host-session://claude/${uuid_0}?chat=${uuid_1} + relationship: independent + stopReason: tool_use + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Call move_session exactly once with session "agent-host-session://claude/${uuid_0}?chat=${uuid_1}" and relationship "independent", then reply exactly "moved". + - role: assistant + content: + - type: tool_use + name: mcp__host__move_session + input: + session: agent-host-session://claude/${uuid_0}?chat=${uuid_1} + relationship: independent + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: Nested session moved to its own session (agent-host-session://ahp-session/${uuid_2}). + response: + content: moved + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-move-session-moves-a-nested-session-into-another-session.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-move-session-moves-a-nested-session-into-another-session.yaml new file mode 100644 index 00000000000000..619c0c2b353838 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-move-session-moves-a-nested-session-into-another-session.yaml @@ -0,0 +1,57 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Reply exactly "MOVE_TARGET_READY". + response: + content: MOVE_TARGET_READY + stopReason: end_turn + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Reply exactly "PEER_READY". + response: + content: PEER_READY + stopReason: end_turn + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Call move_session exactly once with session "agent-host-session://claude/${uuid_0}?chat=${uuid_1}" and into "agent-host-session://claude/${uuid_2}", then reply exactly "moved". + response: + content: + - type: tool_use + id: toolcall_0 + name: mcp__host__move_session + input: + session: agent-host-session://claude/${uuid_0}?chat=${uuid_1} + into: agent-host-session://claude/${uuid_2} + stopReason: tool_use + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Call move_session exactly once with session "agent-host-session://claude/${uuid_0}?chat=${uuid_1}" and into "agent-host-session://claude/${uuid_2}", then reply exactly "moved". + - role: assistant + content: + - type: tool_use + name: mcp__host__move_session + input: + session: agent-host-session://claude/${uuid_0}?chat=${uuid_1} + into: agent-host-session://claude/${uuid_2} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: Nested session moved into agent-host-session://claude/${uuid_2} (agent-host-session://claude/${uuid_2}?chat=${uuid_1}). + response: + content: moved + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-move-session-moves-a-whole-session-into-another-session.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-move-session-moves-a-whole-session-into-another-session.yaml new file mode 100644 index 00000000000000..13b492c2fca34e --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-move-session-moves-a-whole-session-into-another-session.yaml @@ -0,0 +1,48 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Reply exactly "MOVE_LONE_READY". + response: + content: MOVE_LONE_READY + stopReason: end_turn + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Call move_session exactly once with session "agent-host-session://claude/${uuid_0}" and into "agent-host-session://claude/${uuid_1}", then reply exactly "moved". + response: + content: + - type: tool_use + id: toolcall_0 + name: mcp__host__move_session + input: + session: agent-host-session://claude/${uuid_0} + into: agent-host-session://claude/${uuid_1} + stopReason: tool_use + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Call move_session exactly once with session "agent-host-session://claude/${uuid_0}" and into "agent-host-session://claude/${uuid_1}", then reply exactly "moved". + - role: assistant + content: + - type: tool_use + name: mcp__host__move_session + input: + session: agent-host-session://claude/${uuid_0} + into: agent-host-session://claude/${uuid_1} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: Session moved into agent-host-session://claude/${uuid_1} as a nested session (agent-host-session://claude/${uuid_1}?chat=${uuid_2}). + response: + content: moved + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-move-session-moves-the-calling-chat-only-once-its-turn-ends.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-move-session-moves-the-calling-chat-only-once-its-turn-ends.yaml new file mode 100644 index 00000000000000..2db695696ad1bb --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-move-session-moves-the-calling-chat-only-once-its-turn-ends.yaml @@ -0,0 +1,46 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Reply exactly "MOVE_SELF_TARGET_READY". + response: + content: MOVE_SELF_TARGET_READY + stopReason: end_turn + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Call move_session exactly once with into "agent-host-session://claude/${uuid_0}", then reply exactly "scheduled". + response: + content: + - type: tool_use + id: toolcall_0 + name: mcp__host__move_session + input: + into: agent-host-session://claude/${uuid_0} + stopReason: tool_use + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Call move_session exactly once with into "agent-host-session://claude/${uuid_0}", then reply exactly "scheduled". + - role: assistant + content: + - type: tool_use + name: mcp__host__move_session + input: + into: agent-host-session://claude/${uuid_0} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'This chat will be moved into agent-host-session://claude/${uuid_0} when this turn ends. Make this your final tool call: reply with one short sentence, then end the turn. Do not call move_session again for this chat.' + response: + content: scheduled + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-move-session-reorders-a-nested-session-within-its-session.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-move-session-reorders-a-nested-session-within-its-session.yaml new file mode 100644 index 00000000000000..cf02f44e239cb3 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-server-tool-move-session-reorders-a-nested-session-within-its-session.yaml @@ -0,0 +1,59 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Reply exactly "FIRST_READY". + response: + content: FIRST_READY + stopReason: end_turn + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Reply exactly "SECOND_READY". + response: + content: SECOND_READY + stopReason: end_turn + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Call move_session exactly once with session "agent-host-session://claude/${uuid_0}?chat=${uuid_1}", into "agent-host-session://claude/${uuid_0}", and after "agent-host-session://claude/${uuid_0}", then reply exactly "moved". + response: + content: + - type: tool_use + id: toolcall_0 + name: mcp__host__move_session + input: + session: agent-host-session://claude/${uuid_0}?chat=${uuid_1} + into: agent-host-session://claude/${uuid_0} + after: agent-host-session://claude/${uuid_0} + stopReason: tool_use + - request: + model: claude-opus-5.5 + system: ${system} + messages: + - role: user + content: Call move_session exactly once with session "agent-host-session://claude/${uuid_0}?chat=${uuid_1}", into "agent-host-session://claude/${uuid_0}", and after "agent-host-session://claude/${uuid_0}", then reply exactly "moved". + - role: assistant + content: + - type: tool_use + name: mcp__host__move_session + input: + session: agent-host-session://claude/${uuid_0}?chat=${uuid_1} + into: agent-host-session://claude/${uuid_0} + after: agent-host-session://claude/${uuid_0} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: Nested session reordered in its session (agent-host-session://claude/${uuid_0}?chat=${uuid_1}). + response: + content: moved + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-move-session-explains-a-refused-move.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-move-session-explains-a-refused-move.yaml new file mode 100644 index 00000000000000..b200bfb641683c --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-move-session-explains-a-refused-move.yaml @@ -0,0 +1,52 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Reply exactly "MOVE_REFUSED_READY". + response: + content: MOVE_REFUSED_READY + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: You must call move_session exactly once with session "agent-host-session://codex/${uuid_0}" and into "agent-host-session://codex/${uuid_1}" so the host can check the move. Do not refuse on your own. After the tool fails, reply exactly "refused". + response: + content: + - type: text + text: Got it — I’ll run that one `move_session` call now, then report the result. + - type: tool_use + id: toolcall_0 + name: move_session + input: + session: agent-host-session://codex/${uuid_0} + into: agent-host-session://codex/${uuid_1} + stopReason: tool_use + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: You must call move_session exactly once with session "agent-host-session://codex/${uuid_0}" and into "agent-host-session://codex/${uuid_1}" so the host can check the move. Do not refuse on your own. After the tool fails, reply exactly "refused". + - role: assistant + content: Got it — I’ll run that one `move_session` call now, then report the result. + - role: assistant + content: + - type: tool_use + name: move_session + input: + session: agent-host-session://codex/${uuid_0} + into: agent-host-session://codex/${uuid_1} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Server tool move_session failed: Cannot move: This session has nested sessions; move them out first, or move each nested session on its own.' + response: + content: refused + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-move-session-makes-a-nested-session-its-own-session.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-move-session-makes-a-nested-session-its-own-session.yaml new file mode 100644 index 00000000000000..afac8ac94698a3 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-move-session-makes-a-nested-session-its-own-session.yaml @@ -0,0 +1,48 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Reply exactly "PEER_READY". + response: + content: PEER_READY + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call move_session exactly once with session "agent-host-session://codex/${uuid_0}?chat=${uuid_1}" and relationship "independent", then reply exactly "moved". + response: + content: + - type: tool_use + id: toolcall_0 + name: move_session + input: + session: agent-host-session://codex/${uuid_0}?chat=${uuid_1} + relationship: independent + stopReason: tool_use + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call move_session exactly once with session "agent-host-session://codex/${uuid_0}?chat=${uuid_1}" and relationship "independent", then reply exactly "moved". + - role: assistant + content: + - type: tool_use + name: move_session + input: + session: agent-host-session://codex/${uuid_0}?chat=${uuid_1} + relationship: independent + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: Nested session moved to its own session (agent-host-session://ahp-session/${uuid_2}). + response: + content: moved + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-move-session-moves-a-nested-session-into-another-session.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-move-session-moves-a-nested-session-into-another-session.yaml new file mode 100644 index 00000000000000..7583e806e213d6 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-move-session-moves-a-nested-session-into-another-session.yaml @@ -0,0 +1,57 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Reply exactly "MOVE_TARGET_READY". + response: + content: MOVE_TARGET_READY + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Reply exactly "PEER_READY". + response: + content: PEER_READY + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call move_session exactly once with session "agent-host-session://codex/${uuid_0}?chat=${uuid_1}" and into "agent-host-session://codex/${uuid_2}", then reply exactly "moved". + response: + content: + - type: tool_use + id: toolcall_0 + name: move_session + input: + session: agent-host-session://codex/${uuid_0}?chat=${uuid_1} + into: agent-host-session://codex/${uuid_2} + stopReason: tool_use + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call move_session exactly once with session "agent-host-session://codex/${uuid_0}?chat=${uuid_1}" and into "agent-host-session://codex/${uuid_2}", then reply exactly "moved". + - role: assistant + content: + - type: tool_use + name: move_session + input: + session: agent-host-session://codex/${uuid_0}?chat=${uuid_1} + into: agent-host-session://codex/${uuid_2} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: Nested session moved into agent-host-session://codex/${uuid_2} (agent-host-session://codex/${uuid_2}?chat=${uuid_1}). + response: + content: moved + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-move-session-moves-a-whole-session-into-another-session.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-move-session-moves-a-whole-session-into-another-session.yaml new file mode 100644 index 00000000000000..ceb4d9a8dfbc55 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-move-session-moves-a-whole-session-into-another-session.yaml @@ -0,0 +1,52 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Reply exactly "MOVE_LONE_READY". + response: + content: MOVE_LONE_READY + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call move_session exactly once with session "agent-host-session://codex/${uuid_0}" and into "agent-host-session://codex/${uuid_1}", then reply exactly "moved". + response: + content: + - type: text + text: Got it — moving that session now. + - type: tool_use + id: toolcall_0 + name: move_session + input: + session: agent-host-session://codex/${uuid_0} + into: agent-host-session://codex/${uuid_1} + stopReason: tool_use + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call move_session exactly once with session "agent-host-session://codex/${uuid_0}" and into "agent-host-session://codex/${uuid_1}", then reply exactly "moved". + - role: assistant + content: Got it — moving that session now. + - role: assistant + content: + - type: tool_use + name: move_session + input: + session: agent-host-session://codex/${uuid_0} + into: agent-host-session://codex/${uuid_1} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: Session moved into agent-host-session://codex/${uuid_1} as a nested session (agent-host-session://codex/${uuid_1}?chat=${uuid_2}). + response: + content: moved + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-move-session-moves-the-calling-chat-only-once-its-turn-ends.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-move-session-moves-the-calling-chat-only-once-its-turn-ends.yaml new file mode 100644 index 00000000000000..1621a9f67133c3 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-move-session-moves-the-calling-chat-only-once-its-turn-ends.yaml @@ -0,0 +1,50 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Reply exactly "MOVE_SELF_TARGET_READY". + response: + content: MOVE_SELF_TARGET_READY + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call move_session exactly once with into "agent-host-session://codex/${uuid_0}", then reply exactly "scheduled". + response: + content: + - type: text + text: Got it — I’ll move this session once, then confirm. + - type: tool_use + id: toolcall_0 + name: move_session + input: + into: agent-host-session://codex/${uuid_0} + stopReason: tool_use + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call move_session exactly once with into "agent-host-session://codex/${uuid_0}", then reply exactly "scheduled". + - role: assistant + content: Got it — I’ll move this session once, then confirm. + - role: assistant + content: + - type: tool_use + name: move_session + input: + into: agent-host-session://codex/${uuid_0} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'This chat will be moved into agent-host-session://codex/${uuid_0} when this turn ends. Make this your final tool call: reply with one short sentence, then end the turn. Do not call move_session again for this chat.' + response: + content: scheduled + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-move-session-reorders-a-nested-session-within-its-session.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-move-session-reorders-a-nested-session-within-its-session.yaml new file mode 100644 index 00000000000000..7d0a23dfa7f4f5 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/codex-server-tool-move-session-reorders-a-nested-session-within-its-session.yaml @@ -0,0 +1,63 @@ +version: 1 +dialect: responses +exchanges: + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Reply exactly "FIRST_READY". + response: + content: FIRST_READY + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Reply exactly "SECOND_READY". + response: + content: SECOND_READY + stopReason: end_turn + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call move_session exactly once with session "agent-host-session://codex/${uuid_0}?chat=${uuid_1}", into "agent-host-session://codex/${uuid_0}", and after "agent-host-session://codex/${uuid_0}", then reply exactly "moved". + response: + content: + - type: text + text: Got it — I’ll perform that move now. + - type: tool_use + id: toolcall_0 + name: move_session + input: + session: agent-host-session://codex/${uuid_0}?chat=${uuid_1} + into: agent-host-session://codex/${uuid_0} + after: agent-host-session://codex/${uuid_0} + stopReason: tool_use + - request: + model: gpt-5.3-codex + system: ${system} + messages: + - role: user + content: Call move_session exactly once with session "agent-host-session://codex/${uuid_0}?chat=${uuid_1}", into "agent-host-session://codex/${uuid_0}", and after "agent-host-session://codex/${uuid_0}", then reply exactly "moved". + - role: assistant + content: Got it — I’ll perform that move now. + - role: assistant + content: + - type: tool_use + name: move_session + input: + session: agent-host-session://codex/${uuid_0}?chat=${uuid_1} + into: agent-host-session://codex/${uuid_0} + after: agent-host-session://codex/${uuid_0} + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: Nested session reordered in its session (agent-host-session://codex/${uuid_0}?chat=${uuid_1}). + response: + content: moved + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-move-session-explains-a-refused-move.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-move-session-explains-a-refused-move.yaml new file mode 100644 index 00000000000000..30e212902cca8b --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-move-session-explains-a-refused-move.yaml @@ -0,0 +1,49 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "MOVE_REFUSED_READY". + response: + content: MOVE_REFUSED_READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: You must call move_session exactly once with session "agent-host-session://copilotcli/e2e-server-tools-move-refused-other" and into "agent-host-session://copilotcli/e2e-server-tools-move-refused" so the host can check the move. Do not refuse on your own. After the tool fails, reply exactly "refused". + response: + content: + - type: tool_use + id: toolcall_0 + name: move_session + input: + session: agent-host-session://copilotcli/e2e-server-tools-move-refused-other + into: agent-host-session://copilotcli/e2e-server-tools-move-refused + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: You must call move_session exactly once with session "agent-host-session://copilotcli/e2e-server-tools-move-refused-other" and into "agent-host-session://copilotcli/e2e-server-tools-move-refused" so the host can check the move. Do not refuse on your own. After the tool fails, reply exactly "refused". + - role: assistant + content: + - type: thinking + - type: tool_use + name: move_session + input: + session: agent-host-session://copilotcli/e2e-server-tools-move-refused-other + into: agent-host-session://copilotcli/e2e-server-tools-move-refused + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'Cannot move: This session has nested sessions; move them out first, or move each nested session on its own.' + response: + content: refused + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-move-session-makes-a-nested-session-its-own-session.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-move-session-makes-a-nested-session-its-own-session.yaml new file mode 100644 index 00000000000000..bf1c8453e3fc27 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-move-session-makes-a-nested-session-its-own-session.yaml @@ -0,0 +1,49 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "PEER_READY". + response: + content: PEER_READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call move_session exactly once with session "agent-host-session://copilotcli/e2e-server-tools-move-promote?chat=${uuid_0}" and relationship "independent", then reply exactly "moved". + response: + content: + - type: tool_use + id: toolcall_0 + name: move_session + input: + session: agent-host-session://copilotcli/e2e-server-tools-move-promote?chat=${uuid_0} + relationship: independent + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call move_session exactly once with session "agent-host-session://copilotcli/e2e-server-tools-move-promote?chat=${uuid_0}" and relationship "independent", then reply exactly "moved". + - role: assistant + content: + - type: thinking + - type: tool_use + name: move_session + input: + session: agent-host-session://copilotcli/e2e-server-tools-move-promote?chat=${uuid_0} + relationship: independent + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: Nested session moved to its own session (agent-host-session://ahp-session/${uuid_1}). + response: + content: moved + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-move-session-moves-a-nested-session-into-another-session.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-move-session-moves-a-nested-session-into-another-session.yaml new file mode 100644 index 00000000000000..16d4ce4f944a38 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-move-session-moves-a-nested-session-into-another-session.yaml @@ -0,0 +1,58 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "MOVE_TARGET_READY". + response: + content: MOVE_TARGET_READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "PEER_READY". + response: + content: PEER_READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call move_session exactly once with session "agent-host-session://copilotcli/e2e-server-tools-move-into?chat=${uuid_0}" and into "agent-host-session://copilotcli/e2e-server-tools-move-into-target", then reply exactly "moved". + response: + content: + - type: tool_use + id: toolcall_0 + name: move_session + input: + session: agent-host-session://copilotcli/e2e-server-tools-move-into?chat=${uuid_0} + into: agent-host-session://copilotcli/e2e-server-tools-move-into-target + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call move_session exactly once with session "agent-host-session://copilotcli/e2e-server-tools-move-into?chat=${uuid_0}" and into "agent-host-session://copilotcli/e2e-server-tools-move-into-target", then reply exactly "moved". + - role: assistant + content: + - type: thinking + - type: tool_use + name: move_session + input: + session: agent-host-session://copilotcli/e2e-server-tools-move-into?chat=${uuid_0} + into: agent-host-session://copilotcli/e2e-server-tools-move-into-target + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: Nested session moved into agent-host-session://copilotcli/e2e-server-tools-move-into-target (agent-host-session://copilotcli/e2e-server-tools-move-into-target?chat=${uuid_0}). + response: + content: moved + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-move-session-moves-a-whole-session-into-another-session.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-move-session-moves-a-whole-session-into-another-session.yaml new file mode 100644 index 00000000000000..3c8c10e20a45c2 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-move-session-moves-a-whole-session-into-another-session.yaml @@ -0,0 +1,48 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "MOVE_LONE_READY". + response: + content: MOVE_LONE_READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call move_session exactly once with session "agent-host-session://copilotcli/e2e-server-tools-move-session-lone" and into "agent-host-session://copilotcli/e2e-server-tools-move-session", then reply exactly "moved". + response: + content: + - type: tool_use + id: toolcall_0 + name: move_session + input: + session: agent-host-session://copilotcli/e2e-server-tools-move-session-lone + into: agent-host-session://copilotcli/e2e-server-tools-move-session + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call move_session exactly once with session "agent-host-session://copilotcli/e2e-server-tools-move-session-lone" and into "agent-host-session://copilotcli/e2e-server-tools-move-session", then reply exactly "moved". + - role: assistant + content: + - type: tool_use + name: move_session + input: + session: agent-host-session://copilotcli/e2e-server-tools-move-session-lone + into: agent-host-session://copilotcli/e2e-server-tools-move-session + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: Session moved into agent-host-session://copilotcli/e2e-server-tools-move-session as a nested session (agent-host-session://copilotcli/e2e-server-tools-move-session?chat=${uuid_0}). + response: + content: moved + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-move-session-moves-the-calling-chat-only-once-its-turn-ends.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-move-session-moves-the-calling-chat-only-once-its-turn-ends.yaml new file mode 100644 index 00000000000000..a7efb84290e54b --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-move-session-moves-the-calling-chat-only-once-its-turn-ends.yaml @@ -0,0 +1,47 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "MOVE_SELF_TARGET_READY". + response: + content: MOVE_SELF_TARGET_READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call move_session exactly once with into "agent-host-session://copilotcli/e2e-server-tools-move-self-target", then reply exactly "scheduled". + response: + content: + - type: tool_use + id: toolcall_0 + name: move_session + input: + into: agent-host-session://copilotcli/e2e-server-tools-move-self-target + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call move_session exactly once with into "agent-host-session://copilotcli/e2e-server-tools-move-self-target", then reply exactly "scheduled". + - role: assistant + content: + - type: thinking + - type: tool_use + name: move_session + input: + into: agent-host-session://copilotcli/e2e-server-tools-move-self-target + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'This chat will be moved into agent-host-session://copilotcli/e2e-server-tools-move-self-target when this turn ends. Make this your final tool call: reply with one short sentence, then end the turn. Do not call move_session again for this chat.' + response: + content: scheduled + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-move-session-reorders-a-nested-session-within-its-session.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-move-session-reorders-a-nested-session-within-its-session.yaml new file mode 100644 index 00000000000000..7f6225b2075b82 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-server-tool-move-session-reorders-a-nested-session-within-its-session.yaml @@ -0,0 +1,59 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "FIRST_READY". + response: + content: FIRST_READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "SECOND_READY". + response: + content: SECOND_READY + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call move_session exactly once with session "agent-host-session://copilotcli/e2e-server-tools-move-reorder?chat=${uuid_0}", into "agent-host-session://copilotcli/e2e-server-tools-move-reorder", and after "agent-host-session://copilotcli/e2e-server-tools-move-reorder", then reply exactly "moved". + response: + content: + - type: tool_use + id: toolcall_0 + name: move_session + input: + session: agent-host-session://copilotcli/e2e-server-tools-move-reorder?chat=${uuid_0} + into: agent-host-session://copilotcli/e2e-server-tools-move-reorder + after: agent-host-session://copilotcli/e2e-server-tools-move-reorder + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Call move_session exactly once with session "agent-host-session://copilotcli/e2e-server-tools-move-reorder?chat=${uuid_0}", into "agent-host-session://copilotcli/e2e-server-tools-move-reorder", and after "agent-host-session://copilotcli/e2e-server-tools-move-reorder", then reply exactly "moved". + - role: assistant + content: + - type: tool_use + name: move_session + input: + session: agent-host-session://copilotcli/e2e-server-tools-move-reorder?chat=${uuid_0} + into: agent-host-session://copilotcli/e2e-server-tools-move-reorder + after: agent-host-session://copilotcli/e2e-server-tools-move-reorder + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: Nested session reordered in its session (agent-host-session://copilotcli/e2e-server-tools-move-reorder?chat=${uuid_0}). + response: + content: moved + stopReason: end_turn 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 aa0bb382d125d5..f088e431050a8d 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 @@ -1022,7 +1022,7 @@ }, { "name": "list_sessions", - "description": "List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Each result includes `session` for identity and tool inputs and `openLink` for clickable Markdown links; do not use `session` as a link target. Pass `session` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.", + "description": "List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Each result includes `session` for identity and tool inputs and `openLink` for clickable Markdown links; do not use `session` as a link target. A session with nested sessions also lists `chats`: its main session (`main: true`) and its nested sessions in order, each with a `title` and an `openLink` that names that chat in `send_message`, `get_session_context` and `rename_chat`. Pass `session` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.", "input_schema": { "type": "object", "properties": { @@ -1077,7 +1077,7 @@ }, { "name": "get_current_session", - "description": "Get metadata and the open link for the session this conversation is running in. Use this to reference the current session (for example before adding a chat to it).", + "description": "Get metadata and the open link for the session this conversation is running in, with its nested sessions in `chats` like `list_sessions`. Use this to reference the current session or one of its nested sessions (for example before adding a chat to it).", "input_schema": { "type": "object", "properties": {} 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 c4611211c42aec..15d6af94721be1 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 @@ -1022,7 +1022,7 @@ }, { "name": "list_sessions", - "description": "List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Each result includes `session` for identity and tool inputs and `openLink` for clickable Markdown links; do not use `session` as a link target. Pass `session` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.", + "description": "List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Each result includes `session` for identity and tool inputs and `openLink` for clickable Markdown links; do not use `session` as a link target. A session with nested sessions also lists `chats`: its main session (`main: true`) and its nested sessions in order, each with a `title` and an `openLink` that names that chat in `send_message`, `get_session_context` and `rename_chat`. Pass `session` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.", "input_schema": { "type": "object", "properties": { @@ -1077,7 +1077,7 @@ }, { "name": "get_current_session", - "description": "Get metadata and the open link for the session this conversation is running in. Use this to reference the current session (for example before adding a chat to it).", + "description": "Get metadata and the open link for the session this conversation is running in, with its nested sessions in `chats` like `list_sessions`. Use this to reference the current session or one of its nested sessions (for example before adding a chat to it).", "input_schema": { "type": "object", "properties": {} 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 b1a0b8d684a23c..60268bb0acb77e 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 @@ -1022,7 +1022,7 @@ }, { "name": "list_sessions", - "description": "List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Each result includes `session` for identity and tool inputs and `openLink` for clickable Markdown links; do not use `session` as a link target. Pass `session` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.", + "description": "List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Each result includes `session` for identity and tool inputs and `openLink` for clickable Markdown links; do not use `session` as a link target. A session with nested sessions also lists `chats`: its main session (`main: true`) and its nested sessions in order, each with a `title` and an `openLink` that names that chat in `send_message`, `get_session_context` and `rename_chat`. Pass `session` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.", "input_schema": { "type": "object", "properties": { @@ -1077,7 +1077,7 @@ }, { "name": "get_current_session", - "description": "Get metadata and the open link for the session this conversation is running in. Use this to reference the current session (for example before adding a chat to it).", + "description": "Get metadata and the open link for the session this conversation is running in, with its nested sessions in `chats` like `list_sessions`. Use this to reference the current session or one of its nested sessions (for example before adding a chat to it).", "input_schema": { "type": "object", "properties": {} 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 0f48c28865af9e..dfe69c14e2e699 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 @@ -1022,7 +1022,7 @@ }, { "name": "list_sessions", - "description": "List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Each result includes `session` for identity and tool inputs and `openLink` for clickable Markdown links; do not use `session` as a link target. Pass `session` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.", + "description": "List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Each result includes `session` for identity and tool inputs and `openLink` for clickable Markdown links; do not use `session` as a link target. A session with nested sessions also lists `chats`: its main session (`main: true`) and its nested sessions in order, each with a `title` and an `openLink` that names that chat in `send_message`, `get_session_context` and `rename_chat`. Pass `session` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.", "input_schema": { "type": "object", "properties": { @@ -1077,7 +1077,7 @@ }, { "name": "get_current_session", - "description": "Get metadata and the open link for the session this conversation is running in. Use this to reference the current session (for example before adding a chat to it).", + "description": "Get metadata and the open link for the session this conversation is running in, with its nested sessions in `chats` like `list_sessions`. Use this to reference the current session or one of its nested sessions (for example before adding a chat to it).", "input_schema": { "type": "object", "properties": {} 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 f4e9d9b8aef3bf..72236b287c22d3 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 @@ -1022,7 +1022,7 @@ }, { "name": "list_sessions", - "description": "List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Each result includes `session` for identity and tool inputs and `openLink` for clickable Markdown links; do not use `session` as a link target. Pass `session` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.", + "description": "List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Each result includes `session` for identity and tool inputs and `openLink` for clickable Markdown links; do not use `session` as a link target. A session with nested sessions also lists `chats`: its main session (`main: true`) and its nested sessions in order, each with a `title` and an `openLink` that names that chat in `send_message`, `get_session_context` and `rename_chat`. Pass `session` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.", "input_schema": { "type": "object", "properties": { @@ -1077,7 +1077,7 @@ }, { "name": "get_current_session", - "description": "Get metadata and the open link for the session this conversation is running in. Use this to reference the current session (for example before adding a chat to it).", + "description": "Get metadata and the open link for the session this conversation is running in, with its nested sessions in `chats` like `list_sessions`. Use this to reference the current session or one of its nested sessions (for example before adding a chat to it).", "input_schema": { "type": "object", "properties": {} 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 6d0594e25868e3..439fa1e0dfa8f6 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 @@ -1022,7 +1022,7 @@ }, { "name": "list_sessions", - "description": "List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Each result includes `session` for identity and tool inputs and `openLink` for clickable Markdown links; do not use `session` as a link target. Pass `session` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.", + "description": "List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Each result includes `session` for identity and tool inputs and `openLink` for clickable Markdown links; do not use `session` as a link target. A session with nested sessions also lists `chats`: its main session (`main: true`) and its nested sessions in order, each with a `title` and an `openLink` that names that chat in `send_message`, `get_session_context` and `rename_chat`. Pass `session` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.", "input_schema": { "type": "object", "properties": { @@ -1077,7 +1077,7 @@ }, { "name": "get_current_session", - "description": "Get metadata and the open link for the session this conversation is running in. Use this to reference the current session (for example before adding a chat to it).", + "description": "Get metadata and the open link for the session this conversation is running in, with its nested sessions in `chats` like `list_sessions`. Use this to reference the current session or one of its nested sessions (for example before adding a chat to it).", "input_schema": { "type": "object", "properties": {} 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 0e170783dd71d9..6406ddb1ee5448 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 @@ -1022,7 +1022,7 @@ }, { "name": "list_sessions", - "description": "List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Each result includes `session` for identity and tool inputs and `openLink` for clickable Markdown links; do not use `session` as a link target. Pass `session` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.", + "description": "List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Each result includes `session` for identity and tool inputs and `openLink` for clickable Markdown links; do not use `session` as a link target. A session with nested sessions also lists `chats`: its main session (`main: true`) and its nested sessions in order, each with a `title` and an `openLink` that names that chat in `send_message`, `get_session_context` and `rename_chat`. Pass `session` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.", "input_schema": { "type": "object", "properties": { @@ -1077,7 +1077,7 @@ }, { "name": "get_current_session", - "description": "Get metadata and the open link for the session this conversation is running in. Use this to reference the current session (for example before adding a chat to it).", + "description": "Get metadata and the open link for the session this conversation is running in, with its nested sessions in `chats` like `list_sessions`. Use this to reference the current session or one of its nested sessions (for example before adding a chat to it).", "input_schema": { "type": "object", "properties": {} 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 375a3482b189e1..400329cc9a64b3 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 @@ -1022,7 +1022,7 @@ }, { "name": "list_sessions", - "description": "List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Each result includes `session` for identity and tool inputs and `openLink` for clickable Markdown links; do not use `session` as a link target. Pass `session` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.", + "description": "List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Each result includes `session` for identity and tool inputs and `openLink` for clickable Markdown links; do not use `session` as a link target. A session with nested sessions also lists `chats`: its main session (`main: true`) and its nested sessions in order, each with a `title` and an `openLink` that names that chat in `send_message`, `get_session_context` and `rename_chat`. Pass `session` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.", "input_schema": { "type": "object", "properties": { @@ -1077,7 +1077,7 @@ }, { "name": "get_current_session", - "description": "Get metadata and the open link for the session this conversation is running in. Use this to reference the current session (for example before adding a chat to it).", + "description": "Get metadata and the open link for the session this conversation is running in, with its nested sessions in `chats` like `list_sessions`. Use this to reference the current session or one of its nested sessions (for example before adding a chat to it).", "input_schema": { "type": "object", "properties": {} 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 2833992b56e966..f90459b4dfc0a5 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 @@ -1022,7 +1022,7 @@ }, { "name": "list_sessions", - "description": "List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Each result includes `session` for identity and tool inputs and `openLink` for clickable Markdown links; do not use `session` as a link target. Pass `session` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.", + "description": "List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Each result includes `session` for identity and tool inputs and `openLink` for clickable Markdown links; do not use `session` as a link target. A session with nested sessions also lists `chats`: its main session (`main: true`) and its nested sessions in order, each with a `title` and an `openLink` that names that chat in `send_message`, `get_session_context` and `rename_chat`. Pass `session` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.", "input_schema": { "type": "object", "properties": { @@ -1077,7 +1077,7 @@ }, { "name": "get_current_session", - "description": "Get metadata and the open link for the session this conversation is running in. Use this to reference the current session (for example before adding a chat to it).", + "description": "Get metadata and the open link for the session this conversation is running in, with its nested sessions in `chats` like `list_sessions`. Use this to reference the current session or one of its nested sessions (for example before adding a chat to it).", "input_schema": { "type": "object", "properties": {} 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 59d6a6287572d8..262582a261f6df 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 @@ -1052,7 +1052,7 @@ }, { "name": "list_sessions", - "description": "List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Each result includes `session` for identity and tool inputs and `openLink` for clickable Markdown links; do not use `session` as a link target. Pass `session` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.", + "description": "List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Each result includes `session` for identity and tool inputs and `openLink` for clickable Markdown links; do not use `session` as a link target. A session with nested sessions also lists `chats`: its main session (`main: true`) and its nested sessions in order, each with a `title` and an `openLink` that names that chat in `send_message`, `get_session_context` and `rename_chat`. Pass `session` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.", "parameters": { "type": "object", "properties": { @@ -1109,7 +1109,7 @@ }, { "name": "get_current_session", - "description": "Get metadata and the open link for the session this conversation is running in. Use this to reference the current session (for example before adding a chat to it).", + "description": "Get metadata and the open link for the session this conversation is running in, with its nested sessions in `chats` like `list_sessions`. Use this to reference the current session or one of its nested sessions (for example before adding a chat to it).", "parameters": { "type": "object", "properties": {} 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 3e815e0d313e11..965ce5d6cae9e7 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 @@ -1013,7 +1013,7 @@ }, { "name": "list_sessions", - "description": "List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Each result includes `session` for identity and tool inputs and `openLink` for clickable Markdown links; do not use `session` as a link target. Pass `session` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.", + "description": "List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Each result includes `session` for identity and tool inputs and `openLink` for clickable Markdown links; do not use `session` as a link target. A session with nested sessions also lists `chats`: its main session (`main: true`) and its nested sessions in order, each with a `title` and an `openLink` that names that chat in `send_message`, `get_session_context` and `rename_chat`. Pass `session` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.", "parameters": { "type": "object", "properties": { @@ -1070,7 +1070,7 @@ }, { "name": "get_current_session", - "description": "Get metadata and the open link for the session this conversation is running in. Use this to reference the current session (for example before adding a chat to it).", + "description": "Get metadata and the open link for the session this conversation is running in, with its nested sessions in `chats` like `list_sessions`. Use this to reference the current session or one of its nested sessions (for example before adding a chat to it).", "parameters": { "type": "object", "properties": {} 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 6cc25b124fc5b3..8703427da40500 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 @@ -1052,7 +1052,7 @@ }, { "name": "list_sessions", - "description": "List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Each result includes `session` for identity and tool inputs and `openLink` for clickable Markdown links; do not use `session` as a link target. Pass `session` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.", + "description": "List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Each result includes `session` for identity and tool inputs and `openLink` for clickable Markdown links; do not use `session` as a link target. A session with nested sessions also lists `chats`: its main session (`main: true`) and its nested sessions in order, each with a `title` and an `openLink` that names that chat in `send_message`, `get_session_context` and `rename_chat`. Pass `session` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.", "parameters": { "type": "object", "properties": { @@ -1109,7 +1109,7 @@ }, { "name": "get_current_session", - "description": "Get metadata and the open link for the session this conversation is running in. Use this to reference the current session (for example before adding a chat to it).", + "description": "Get metadata and the open link for the session this conversation is running in, with its nested sessions in `chats` like `list_sessions`. Use this to reference the current session or one of its nested sessions (for example before adding a chat to it).", "parameters": { "type": "object", "properties": {} 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 b8d292aae802fd..4326f2ecb747b7 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 @@ -1052,7 +1052,7 @@ }, { "name": "list_sessions", - "description": "List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Each result includes `session` for identity and tool inputs and `openLink` for clickable Markdown links; do not use `session` as a link target. Pass `session` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.", + "description": "List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Each result includes `session` for identity and tool inputs and `openLink` for clickable Markdown links; do not use `session` as a link target. A session with nested sessions also lists `chats`: its main session (`main: true`) and its nested sessions in order, each with a `title` and an `openLink` that names that chat in `send_message`, `get_session_context` and `rename_chat`. Pass `session` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.", "parameters": { "type": "object", "properties": { @@ -1109,7 +1109,7 @@ }, { "name": "get_current_session", - "description": "Get metadata and the open link for the session this conversation is running in. Use this to reference the current session (for example before adding a chat to it).", + "description": "Get metadata and the open link for the session this conversation is running in, with its nested sessions in `chats` like `list_sessions`. Use this to reference the current session or one of its nested sessions (for example before adding a chat to it).", "parameters": { "type": "object", "properties": {} 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 6728be62cbe9d1..88cb96d9c3f92e 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 @@ -1013,7 +1013,7 @@ }, { "name": "list_sessions", - "description": "List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Each result includes `session` for identity and tool inputs and `openLink` for clickable Markdown links; do not use `session` as a link target. Pass `session` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.", + "description": "List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Each result includes `session` for identity and tool inputs and `openLink` for clickable Markdown links; do not use `session` as a link target. A session with nested sessions also lists `chats`: its main session (`main: true`) and its nested sessions in order, each with a `title` and an `openLink` that names that chat in `send_message`, `get_session_context` and `rename_chat`. Pass `session` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.", "parameters": { "type": "object", "properties": { @@ -1070,7 +1070,7 @@ }, { "name": "get_current_session", - "description": "Get metadata and the open link for the session this conversation is running in. Use this to reference the current session (for example before adding a chat to it).", + "description": "Get metadata and the open link for the session this conversation is running in, with its nested sessions in `chats` like `list_sessions`. Use this to reference the current session or one of its nested sessions (for example before adding a chat to it).", "parameters": { "type": "object", "properties": {} 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 aa791ebafed566..009f52db9f1e71 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 @@ -1013,7 +1013,7 @@ }, { "name": "list_sessions", - "description": "List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Each result includes `session` for identity and tool inputs and `openLink` for clickable Markdown links; do not use `session` as a link target. Pass `session` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.", + "description": "List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Each result includes `session` for identity and tool inputs and `openLink` for clickable Markdown links; do not use `session` as a link target. A session with nested sessions also lists `chats`: its main session (`main: true`) and its nested sessions in order, each with a `title` and an `openLink` that names that chat in `send_message`, `get_session_context` and `rename_chat`. Pass `session` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.", "parameters": { "type": "object", "properties": { @@ -1070,7 +1070,7 @@ }, { "name": "get_current_session", - "description": "Get metadata and the open link for the session this conversation is running in. Use this to reference the current session (for example before adding a chat to it).", + "description": "Get metadata and the open link for the session this conversation is running in, with its nested sessions in `chats` like `list_sessions`. Use this to reference the current session or one of its nested sessions (for example before adding a chat to it).", "parameters": { "type": "object", "properties": {} 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 9c0a3f4bd2fb28..360a559fa7d44c 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 @@ -1052,7 +1052,7 @@ }, { "name": "list_sessions", - "description": "List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Each result includes `session` for identity and tool inputs and `openLink` for clickable Markdown links; do not use `session` as a link target. Pass `session` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.", + "description": "List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Each result includes `session` for identity and tool inputs and `openLink` for clickable Markdown links; do not use `session` as a link target. A session with nested sessions also lists `chats`: its main session (`main: true`) and its nested sessions in order, each with a `title` and an `openLink` that names that chat in `send_message`, `get_session_context` and `rename_chat`. Pass `session` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.", "parameters": { "type": "object", "properties": { @@ -1109,7 +1109,7 @@ }, { "name": "get_current_session", - "description": "Get metadata and the open link for the session this conversation is running in. Use this to reference the current session (for example before adding a chat to it).", + "description": "Get metadata and the open link for the session this conversation is running in, with its nested sessions in `chats` like `list_sessions`. Use this to reference the current session or one of its nested sessions (for example before adding a chat to it).", "parameters": { "type": "object", "properties": {} 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 1d6b9ccd7793b4..e05a7fc68cfb00 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 @@ -1032,7 +1032,7 @@ }, { "name": "list_sessions", - "description": "List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Each result includes `session` for identity and tool inputs and `openLink` for clickable Markdown links; do not use `session` as a link target. Pass `session` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.", + "description": "List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Each result includes `session` for identity and tool inputs and `openLink` for clickable Markdown links; do not use `session` as a link target. A session with nested sessions also lists `chats`: its main session (`main: true`) and its nested sessions in order, each with a `title` and an `openLink` that names that chat in `send_message`, `get_session_context` and `rename_chat`. Pass `session` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.", "parameters": { "type": "object", "properties": { @@ -1089,7 +1089,7 @@ }, { "name": "get_current_session", - "description": "Get metadata and the open link for the session this conversation is running in. Use this to reference the current session (for example before adding a chat to it).", + "description": "Get metadata and the open link for the session this conversation is running in, with its nested sessions in `chats` like `list_sessions`. Use this to reference the current session or one of its nested sessions (for example before adding a chat to it).", "parameters": { "type": "object", "properties": {} 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 3ce217bb4ebc3f..5b6e4443fc492c 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 @@ -1032,7 +1032,7 @@ }, { "name": "list_sessions", - "description": "List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Each result includes `session` for identity and tool inputs and `openLink` for clickable Markdown links; do not use `session` as a link target. Pass `session` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.", + "description": "List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Each result includes `session` for identity and tool inputs and `openLink` for clickable Markdown links; do not use `session` as a link target. A session with nested sessions also lists `chats`: its main session (`main: true`) and its nested sessions in order, each with a `title` and an `openLink` that names that chat in `send_message`, `get_session_context` and `rename_chat`. Pass `session` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.", "parameters": { "type": "object", "properties": { @@ -1089,7 +1089,7 @@ }, { "name": "get_current_session", - "description": "Get metadata and the open link for the session this conversation is running in. Use this to reference the current session (for example before adding a chat to it).", + "description": "Get metadata and the open link for the session this conversation is running in, with its nested sessions in `chats` like `list_sessions`. Use this to reference the current session or one of its nested sessions (for example before adding a chat to it).", "parameters": { "type": "object", "properties": {} 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 cee44e3b40318b..486b840a983aa1 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 @@ -1032,7 +1032,7 @@ }, { "name": "list_sessions", - "description": "List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Each result includes `session` for identity and tool inputs and `openLink` for clickable Markdown links; do not use `session` as a link target. Pass `session` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.", + "description": "List sessions and their compact metadata (status, activity, working directory, project, worktree changes, git/GitHub info, timestamps). Each result includes `session` for identity and tool inputs and `openLink` for clickable Markdown links; do not use `session` as a link target. A session with nested sessions also lists `chats`: its main session (`main: true`) and its nested sessions in order, each with a `title` and an `openLink` that names that chat in `send_message`, `get_session_context` and `rename_chat`. Pass `session` to fetch a single known session by URI. By default archived sessions are omitted. Optionally filter by `status`, `workspace`, `withChanges`, `unread`, `withPullRequest`, `includeArchived`, `createdAfter`, or `createdBefore`.", "parameters": { "type": "object", "properties": { @@ -1089,7 +1089,7 @@ }, { "name": "get_current_session", - "description": "Get metadata and the open link for the session this conversation is running in. Use this to reference the current session (for example before adding a chat to it).", + "description": "Get metadata and the open link for the session this conversation is running in, with its nested sessions in `chats` like `list_sessions`. Use this to reference the current session or one of its nested sessions (for example before adding a chat to it).", "parameters": { "type": "object", "properties": {} 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 d7d5a41cd4e0f2..4a900d2cb3696e 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/serverToolsSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/serverToolsSuite.ts @@ -13,13 +13,15 @@ import { generateUuid } from '../../../../../../base/common/uuid.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'; +import { ChatMoveConfigKey } from '../../../../common/agentHostChatMove.js'; +import { buildOpenSessionLinkUri, parseOpenSessionLinkUri } from '../../../../common/openSessionLink.js'; import { SessionConfigKey } from '../../../../common/sessionConfigKeys.js'; import { ArtifactServerToolName, SessionServerToolName } from '../../../../common/serverToolNames.js'; import { readSessionArtifacts } from '../../../../common/sessionArtifacts.js'; import type { ListSessionsResult, SubscribeResult } from '../../../../common/state/protocol/commands.js'; import { ActionType, NotificationType, type ChatToolCallCompleteAction, type ChatToolCallStartAction, type SessionAddedParams, type StateAction } from '../../../../common/state/sessionActions.js'; import { + getAgentHostChatId, readSessionCreationReference, ROOT_STATE_URI, type AnnotationsState, @@ -31,6 +33,7 @@ import { PROTOCOL_VERSION } from '../../../../common/state/protocol/version/regi import { createRealSession, driveChatTurnToCompletion, driveTurnToCompletion, resolveGitHubToken, textFromContent } from '../harness/agentHostE2ETestHarness.js'; import { summarizeAnthropicRequest, summarizeResponsesRequest } from '../harness/capiWireCodec.js'; import { getActionEnvelope, isActionNotification, defaultChatChannel } from '../../serverIntegrationTestHelpers.js'; +import { newTestChatUri } from '../../hostDefaultChats.js'; import type { IAgentHostE2ETestContext } from './e2eTestContext.js'; import { createTestDirectory, ownCodexProtectedDirectories } from '../harness/testDirectories.js'; @@ -137,6 +140,7 @@ export function defineServerToolsTests(context: IAgentHostE2ETestContext): void resource: 'https://api.github.com', token: config.githubToken ?? resolveGitHubToken(), }, 30_000); + await beforeCreateSession?.(); const session = await addSession(prefix, workspace, true); context.client.clearReceived(); return session; @@ -1125,4 +1129,195 @@ export function defineServerToolsTests(context: IAgentHostE2ETestContext): void const result = await context.client.call('listSessions', { channel: ROOT_STATE_URI }); assert.strictEqual(result.items.some(item => item.resource === session.sessionUri), true); }); + + /** + * Creates the invoking session of a `move_session` test while moving chats is enabled, so the session's agent is + * launched with the tool. Runs with a stable resource: it isn't created with Codex's full-access preset, so every + * provider asks for confirmation. The shared host has moves disabled again afterwards. + */ + async function withChatMovesSession(prefix: string, run: (session: IServerToolTestSession) => Promise): Promise { + try { + const session = await createSession(prefix, true, () => setRootConfig({ [ChatMoveConfigKey.Enabled]: true })); + await run(session); + } finally { + await setRootConfig({ [ChatMoveConfigKey.Enabled]: false }); + } + } + + /** + * Creates a nested session of `sessionUri`. With `marker`, it first runs a turn: like the host's own move tests, + * a nested session that never ran can't be prepared for a move yet. + */ + async function createPeer(sessionUri: string, turnId?: string, marker?: string): Promise { + const chat = newTestChatUri(); + await context.client.call('createChat', { channel: sessionUri, chat }, 30_000); + await chatState(chat); + if (turnId !== undefined && marker !== undefined) { + await driveChatTurnToCompletion(context.client, chat, turnId, `Reply exactly "${marker}".`, reserveClientSequenceBlock()); + } + return chat; + } + + async function chatOrder(sessionUri: string): Promise { + return (await sessionState(sessionUri)).chats.map(chat => chat.resource); + } + + function chatLink(sessionUri: string, chatUri: string): string { + return buildOpenSessionLinkUri(sessionUri, getAgentHostChatId(chatUri)); + } + + function forgetSession(sessionUri: string): void { + const index = createdSessions.indexOf(sessionUri); + if (index >= 0) { + createdSessions.splice(index, 1); + } + } + + serverToolTest('server tool: move_session reorders a nested session within its session', async function () { + await withChatMovesSession('move-reorder', async session => { + const first = await createPeer(session.sessionUri, 'turn-move-reorder-first-seed', 'FIRST_READY'); + const second = await createPeer(session.sessionUri, 'turn-move-reorder-second-seed', 'SECOND_READY'); + const sessionLink = buildOpenSessionLinkUri(session.sessionUri); + const { turn } = await driveServerTool( + session, + 'turn-move-reorder', + `Call move_session exactly once with session "${chatLink(session.sessionUri, second)}", into "${sessionLink}", and after "${sessionLink}", then reply exactly "moved".`, + SessionServerToolName.MoveSession, + { result: [/Nested session reordered/] }, + ); + assert.deepStrictEqual({ + sawPendingConfirmation: turn.sawPendingConfirmation, + chats: await chatOrder(session.sessionUri), + }, { + sawPendingConfirmation: true, + chats: [session.chatUri, second, first], + }); + }); + }, config.supportsMultipleChats); + + serverToolTest('server tool: move_session moves a nested session into another session', async function () { + await withChatMovesSession('move-into', async session => { + const target = await addSession('move-into-target', session.workspace, true); + await materializeSession(target, 'turn-move-into-target-seed', 'MOVE_TARGET_READY'); + const peer = await createPeer(session.sessionUri, 'turn-move-into-peer-seed', 'PEER_READY'); + const { turn } = await driveServerTool( + session, + 'turn-move-into', + `Call move_session exactly once with session "${chatLink(session.sessionUri, peer)}" and into "${buildOpenSessionLinkUri(target.sessionUri)}", then reply exactly "moved".`, + SessionServerToolName.MoveSession, + { result: [/Nested session moved into/] }, + ); + assert.deepStrictEqual({ + sawPendingConfirmation: turn.sawPendingConfirmation, + source: await chatOrder(session.sessionUri), + target: await chatOrder(target.sessionUri), + }, { + sawPendingConfirmation: true, + source: [session.chatUri], + target: [target.chatUri, peer], + }); + }); + }, config.supportsMultipleChats); + + serverToolTest('server tool: move_session makes a nested session its own session', async function () { + await withChatMovesSession('move-promote', async session => { + const peer = await createPeer(session.sessionUri, 'turn-move-promote-peer-seed', 'PEER_READY'); + const { turn, tool } = await driveServerTool( + session, + 'turn-move-promote', + `Call move_session exactly once with session "${chatLink(session.sessionUri, peer)}" and relationship "independent", then reply exactly "moved".`, + SessionServerToolName.MoveSession, + { result: [/Nested session moved to its own session/] }, + ); + const promoted = parseOpenSessionLinkUri(/\((?agent-host-session:\/\/[^)]+)\)/.exec(tool.resultText)?.groups?.link ?? '')?.toString(); + assert.ok(promoted, `expected a link to the new session in: ${tool.resultText}`); + createdSessions.push(promoted); + assert.deepStrictEqual({ + sawPendingConfirmation: turn.sawPendingConfirmation, + source: await chatOrder(session.sessionUri), + promotedDefaultChat: (await sessionState(promoted)).defaultChat, + }, { + sawPendingConfirmation: true, + source: [session.chatUri], + promotedDefaultChat: peer, + }); + }); + }, config.supportsMultipleChats); + + serverToolTest('server tool: move_session moves a whole session into another session', async function () { + await withChatMovesSession('move-session', async session => { + const lone = await addSession('move-session-lone', session.workspace, true); + await materializeSession(lone, 'turn-move-session-lone-seed', 'MOVE_LONE_READY'); + const { turn } = await driveServerTool( + session, + 'turn-move-session', + `Call move_session exactly once with session "${buildOpenSessionLinkUri(lone.sessionUri)}" and into "${buildOpenSessionLinkUri(session.sessionUri)}", then reply exactly "moved".`, + SessionServerToolName.MoveSession, + { result: [/Session moved into .* as a nested session/] }, + ); + const listed = await context.client.call('listSessions', { channel: ROOT_STATE_URI }); + forgetSession(lone.sessionUri); + assert.deepStrictEqual({ + sawPendingConfirmation: turn.sawPendingConfirmation, + chats: await chatOrder(session.sessionUri), + loneListed: listed.items.some(item => item.resource === lone.sessionUri), + }, { + sawPendingConfirmation: true, + chats: [session.chatUri, lone.chatUri], + loneListed: false, + }); + }); + }, config.supportsMultipleChats); + + serverToolTest('server tool: move_session moves the calling chat only once its turn ends', async function () { + await withChatMovesSession('move-self', async session => { + const target = await addSession('move-self-target', session.workspace, true); + await materializeSession(target, 'turn-move-self-target-seed', 'MOVE_SELF_TARGET_READY'); + await sessionState(target.sessionUri); + const peer = await createPeer(session.sessionUri); + context.client.clearReceived(); + const { turn, tool } = await driveServerTool( + { ...session, chatUri: peer }, + 'turn-move-self', + `Call move_session exactly once with into "${buildOpenSessionLinkUri(target.sessionUri)}", then reply exactly "scheduled".`, + SessionServerToolName.MoveSession, + { result: [/This chat will be moved into/] }, + ); + await context.client.waitForNotification(n => isActionNotification(n, ActionType.SessionChatAdded) + && getActionEnvelope(n).channel === target.sessionUri + && (getActionEnvelope(n).action as { summary: { resource: string } }).summary.resource === peer, 30_000); + const notifications = context.client.receivedNotifications(() => true); + const turnEnded = notifications.findIndex(n => isActionNotification(n, ActionType.ChatTurnComplete) && getActionEnvelope(n).channel === peer); + const added = notifications.findIndex(n => isActionNotification(n, ActionType.SessionChatAdded) && getActionEnvelope(n).channel === target.sessionUri); + assert.deepStrictEqual({ + sawPendingConfirmation: turn.sawPendingConfirmation, + toolCompleted: tool.completion.result.success, + movedAfterTurnEnded: turnEnded >= 0 && added > turnEnded, + source: await chatOrder(session.sessionUri), + target: await chatOrder(target.sessionUri), + }, { + sawPendingConfirmation: true, + toolCompleted: true, + movedAfterTurnEnded: true, + source: [session.chatUri], + target: [target.chatUri, peer], + }); + }); + }, config.supportsMultipleChats); + + serverToolTest('server tool: move_session explains a refused move', async function () { + await withChatMovesSession('move-refused', async session => { + const other = await addSession('move-refused-other', session.workspace, true); + await materializeSession(other, 'turn-move-refused-other-seed', 'MOVE_REFUSED_READY'); + await createPeer(other.sessionUri); + await driveServerTool( + session, + 'turn-move-refused', + `You must call move_session exactly once with session "${buildOpenSessionLinkUri(other.sessionUri)}" and into "${buildOpenSessionLinkUri(session.sessionUri)}" so the host can check the move. Do not refuse on your own. After the tool fails, reply exactly "refused".`, + SessionServerToolName.MoveSession, + { success: false, result: [/Cannot move: This session has nested sessions/] }, + ); + assert.deepStrictEqual(await chatOrder(session.sessionUri), [session.chatUri]); + }); + }, config.supportsMultipleChats); } diff --git a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts index 9117442a7712b2..9063843592cd7d 100644 --- a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts +++ b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts @@ -1799,6 +1799,143 @@ suite('mapSessionEvents — history replay', () => { }); }); + test('restores blob attachments with base64, data URLs, and unencoded legacy text without throwing', async () => { + const encodedText = encodeBase64(VSBuffer.fromString('Repository name: vscode\nOwner: microsoft')); + const controlCharacterText = 'a\x1Fb\x00c\x1Ed'; + const events: ISessionEvent[] = [ + { + type: 'user.message', + data: { + interactionId: 'm1', + content: 'inspect attachments', + attachments: [ + { + type: 'blob', + mimeType: 'text/plain; x-vscode-display-kind=workspace', + displayName: 'microsoft/vscode', + data: encodedText, + }, + { + type: 'blob', + mimeType: 'text/plain', + displayName: 'raw-space', + data: 'Repository name: vscode', + }, + { + type: 'blob', + mimeType: 'text/plain', + displayName: 'raw-colon', + data: 'https://github.com/microsoft/vscode', + }, + { + type: 'blob', + mimeType: 'text/plain', + displayName: 'data-url-b64', + data: `data:text/plain;base64,${encodedText}`, + }, + ...['README', 'a=b', '=', '==', 'abcd='].map(data => ({ + type: 'blob' as const, + mimeType: 'text/plain', + displayName: `raw-${data}`, + data, + })), + { + type: 'blob', + mimeType: 'text/plain', + displayName: 'bom-and-ansi', + data: encodeBase64(VSBuffer.fromString('\uFEFF\x1b[31mred\x1b[0m')), + }, + { + type: 'blob', + mimeType: 'text/plain', + displayName: 'control-characters', + data: encodeBase64(VSBuffer.fromString(controlCharacterText)), + }, + { + type: 'blob', + mimeType: 'text/plain', + displayName: 'data-url-control-characters', + data: `data:text/plain;base64,${encodeBase64(VSBuffer.fromString(controlCharacterText))}`, + }, + { + type: 'blob', + mimeType: 'image/png', + displayName: 'screenshot.png', + data: 'data:image/png;base64,iVBORw0KGgo=\r\n', + }, + ...[['wrapped.bin', 'AAEC\r\nAwQ='], ['raw-not-base64.bin', 'not base64'], ['raw-spaced.bin', 'abcd efgh']].map(([displayName, data]) => ({ + type: 'blob' as const, + mimeType: 'application/octet-stream', + displayName, + data, + })), + ], + }, + }, + { type: 'assistant.message', data: { messageId: 'm2', content: 'done' } }, + ]; + + const { turns } = await mapSessionEvents(session, undefined, toSessionEvents(events)); + + assert.deepStrictEqual(turns[0].message.attachments, [ + { + type: MessageAttachmentKind.Simple, + label: 'microsoft/vscode', + modelRepresentation: 'Repository name: vscode\nOwner: microsoft', + displayKind: 'workspace', + }, + { + type: MessageAttachmentKind.Simple, + label: 'raw-space', + modelRepresentation: 'Repository name: vscode', + }, + { + type: MessageAttachmentKind.Simple, + label: 'raw-colon', + modelRepresentation: 'https://github.com/microsoft/vscode', + }, + { + type: MessageAttachmentKind.Simple, + label: 'data-url-b64', + modelRepresentation: 'Repository name: vscode\nOwner: microsoft', + }, + ...['README', 'a=b', '=', '==', 'abcd='].map(data => ({ + type: MessageAttachmentKind.Simple, + label: `raw-${data}`, + modelRepresentation: data, + })), + { + type: MessageAttachmentKind.Simple, + label: 'bom-and-ansi', + modelRepresentation: '\uFEFF\x1b[31mred\x1b[0m', + }, + { + type: MessageAttachmentKind.Simple, + label: 'control-characters', + modelRepresentation: controlCharacterText, + }, + { + type: MessageAttachmentKind.Simple, + label: 'data-url-control-characters', + modelRepresentation: controlCharacterText, + }, + { + type: MessageAttachmentKind.EmbeddedResource, + label: 'screenshot.png', + data: 'iVBORw0KGgo=', + contentType: 'image/png', + displayKind: 'image', + }, + ...[['wrapped.bin', 'AAECAwQ='], ['raw-not-base64.bin', 'not base64'], ['raw-spaced.bin', 'abcd efgh']].map(([label, data]) => ({ + type: MessageAttachmentKind.EmbeddedResource, + label, + data, + contentType: 'application/octet-stream', + displayKind: undefined, + })), + ]); + }); + test('seeds the model from session.start selectedModel when no launch model is supplied', async () => { const events: ISessionEvent[] = [ { type: 'session.start', data: { selectedModel: 'opus-5' } }, diff --git a/src/vs/platform/agentHost/test/node/missionControlProtocolServer.test.ts b/src/vs/platform/agentHost/test/node/missionControlProtocolServer.test.ts index 347e030a51c8f7..5f02e0889ed019 100644 --- a/src/vs/platform/agentHost/test/node/missionControlProtocolServer.test.ts +++ b/src/vs/platform/agentHost/test/node/missionControlProtocolServer.test.ts @@ -382,6 +382,78 @@ suite('Mission Control WPS', () => { assert.strictEqual(lanes.length, 2); }); + for (const method of ['initialize', 'reconnect'] as const) { + for (const passive of [false, true]) { + test(`client-requested ${method} re-handshakes without a closure and requires fresh authentication, passive=${passive}`, async () => { + await MissionControlSealing.ready(); + const sealing = store.add(new MissionControlSealing()); + const { key, signed } = signingFixture('test-key', '123'); + const groupPrefix = 'user.123.env.environment'; + const socket = new FakeWpsSocket(); + const server = store.add(new MissionControlProtocolServer( + { url: 'wss://wps.test/client/hubs/test', access_token: 'fake-token', groups: { control: `${groupPrefix}.control` } }, + '123', 'environment', new MissionControlControlVerifier('environment', '123', [key]), + () => socket, error => { throw error; }, + () => new MissionControlAuthentication(sealing, '123', 'https://api.github.com', async () => Response.json({ id: 123, type: 'User' }), false), + )); + const lanes: IProtocolTransport[] = []; + const closedLanes: number[] = []; + store.add(server.onConnection(lane => { + const index = lanes.length; + lanes.push(lane); + store.add(lane.onClose(() => closedLanes.push(index))); + store.add(lane.onMessage(message => { + if (hasKey(message, { id: true })) { + lane.send({ jsonrpc: '2.0', id: message.id, result: {} }); + } + })); + })); + const ready = server.connect(); + socket.emit('message', JSON.stringify({ type: 'system', event: 'connected' })); + await ready; + socket.deliver(`${groupPrefix}.control`, signed('client-a', 'rehandshake', passive), 1); + socket.deliverEnvelope(`${groupPrefix}.client.client-a.to-host`, { + kind: 'message', data: { jsonrpc: '2.0', id: 1, method: 'initialize', params: { clientId: 'client-a' } }, + }, 2, '123'); + const encryptionKey = sealing.advertisedKeys.find(key => key.use === 'auth-token')!; + const token = await sealMissionControlCredential({ resource: 'https://api.github.com', token: 'owner-token', key: { ...encryptionKey, use: 'auth-token' } }); + const authentication = { resource: 'https://api.github.com', token }; + await lanes[0].relayAuthenticate!(authentication); + const checkAuthorization = lanes[0].relayCaptureAuthorization!(); + const authenticatedBeforeRefresh: (boolean | undefined)[] = []; + for (const id of [2, 3]) { + socket.deliverEnvelope(`${groupPrefix}.client.client-a.to-host`, { + kind: 'message', data: { jsonrpc: '2.0', id, method, params: { clientId: 'client-a' } }, + }, id + 1, '123'); + const successor = lanes[lanes.length - 1]; + authenticatedBeforeRefresh.push(successor.relayAuthentication?.authenticated); + await successor.relayAuthenticate!(authentication); + } + assert.throws(checkAuthorization, /Relay identity authentication is required/); + assert.throws(() => lanes[0].send({ jsonrpc: '2.0', id: 99, result: {} }), /Mission Control lane closed/); + const successor = lanes[lanes.length - 1]; + const beforeHostClose = socket.publishes.map(frame => frame.data.kind); + const authenticatedAfterRefresh = successor.relayAuthentication?.authenticated; + successor.dispose(); + successor.dispose(); + assert.deepStrictEqual({ + beforeHostClose, + afterHostClose: socket.publishes.map(frame => frame.data.kind), + distinctGenerations: new Set(socket.publishes.map(frame => frame.data.generation)).size, + closureMatchesSuccessor: socket.publishes[3].data.generation === socket.publishes[2].data.generation, + authenticatedBeforeRefresh, authenticatedAfterRefresh, closedLanes, + passive: lanes.map(lane => lane.relayPassive), + }, { + beforeHostClose: ['message', 'message', 'message'], + afterHostClose: ['message', 'message', 'message', 'closed'], + distinctGenerations: 3, closureMatchesSuccessor: true, + authenticatedBeforeRefresh: [false, false], authenticatedAfterRefresh: true, closedLanes: [0, 1, 2], + passive: [passive, passive, passive], + }); + }); + } + } + test('records incoming and outgoing AHP traffic and errors per client and connection generation', async () => { const { key, signed } = signingFixture(); const socket = new FakeWpsSocket(); @@ -526,7 +598,7 @@ suite('Mission Control WPS', () => { 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']); + assert.deepStrictEqual(socket.publishes.map(frame => frame.data.kind), ['message']); }); suite('host publish queue and negotiated batching', () => { @@ -704,7 +776,7 @@ suite('Mission Control WPS', () => { }); for (const method of ['initialize', 'reconnect']) { - test(`keeps a chunk group ahead of its closure and ${method} successor`, async () => { + test(`keeps a chunk group ahead of its ${method} successor without a closure`, async () => { const f = await fixture(); const old = f.open(); f.deliver(`${prefix}.client.client-a.to-host`, { kind: 'message', data: { jsonrpc: '2.0', id: 100, method: 'initialize', params: { clientId: 'client-a' } } }); @@ -724,7 +796,7 @@ suite('Mission Control WPS', () => { beforeAcks: ['message', ...Array(4).fill('chunk')], publications: [ { kind: 'message', oldGeneration: true }, ...Array(6).fill({ kind: 'chunk', oldGeneration: true }), - { kind: 'closed', oldGeneration: true }, { kind: 'message', oldGeneration: false }, + { kind: 'message', oldGeneration: false }, ], distinctGeneration: true, errors: [], }); @@ -971,7 +1043,7 @@ suite('Mission Control WPS', () => { }); for (const method of ['initialize', 'reconnect']) { - test(`resets negotiation on ${method} lane replacement and orders predecessor closure before successor messages`, async () => { + test(`resets negotiation on ${method} lane replacement without a predecessor closure`, async () => { const f = await fixture(); const old = f.open(); f.deliver(`${prefix}.client.client-a.to-host`, { kind: 'message', data: { jsonrpc: '2.0', id: 100, method: 'initialize', params: { clientId: 'client-a' } } }); @@ -986,7 +1058,7 @@ suite('Mission Control WPS', () => { successor.send(response(4)); f.drain(); const oldGeneration = f.socket.publishes[0].data.generation; - const newGeneration = f.socket.publishes[4].data.generation; + const newGeneration = f.socket.publishes[3].data.generation; const beforeAdvertisement = f.socket.publishes.map(frame => ({ kind: frame.data.kind, generation: frame.data.generation })); f.advertise(); successor.send(response(5)); @@ -995,11 +1067,11 @@ suite('Mission Control WPS', () => { f.drain(); assert.notStrictEqual(oldGeneration, newGeneration); assert.deepStrictEqual({ - beforeAdvertisement, reNegotiated: f.socket.publishes.slice(7).map(frame => frame.data), errors: f.errors, + beforeAdvertisement, reNegotiated: f.socket.publishes.slice(6).map(frame => frame.data), errors: f.errors, }, { beforeAdvertisement: [ { kind: 'message', generation: oldGeneration }, { kind: 'message', generation: oldGeneration }, - { kind: 'message', generation: oldGeneration }, { kind: 'closed', generation: oldGeneration }, + { kind: 'message', generation: oldGeneration }, { kind: 'message', generation: newGeneration }, { kind: 'message', generation: newGeneration }, { kind: 'message', generation: newGeneration }, ], reNegotiated: [ diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 5891ff66c4cef5..999f6c9b6699fd 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -2712,8 +2712,11 @@ suite('ProtocolServerHandler', () => { type: 'replay', actions: [], missing: [nativeChat], }); const reconnectedGeneration = publishes.find(publish => publish.data.data.id === 9)?.data.generation; - assert.strictEqual(closures.length, 1); - assert.notStrictEqual(closures[0], reconnectedGeneration); + const initialGeneration = publishes.find(publish => publish.data.data.id === 1)?.data.generation; + assert.deepStrictEqual({ + closures, + freshGeneration: typeof reconnectedGeneration === 'number' && typeof initialGeneration === 'number' && reconnectedGeneration !== initialGeneration, + }, { closures: [], freshGeneration: true }); deliver(toHost, request(10, 'authenticate', { channel: ROOT_STATE_URI, resource: 'https://api.github.com', token: `copilot-sealed.v1.${encryptionKey.keyId}.${Buffer.from(box).toString('base64url')}` }), 11); await relayHandler?.whenIdle(); deliver(toHost, request(11, 'subscribe', { channel: nativeChat }), 12); diff --git a/src/vs/platform/agentHost/test/node/providerIntegration/codexTurnDiff.integrationTest.ts b/src/vs/platform/agentHost/test/node/providerIntegration/codexTurnDiff.integrationTest.ts new file mode 100644 index 00000000000000..389fea536ab80c --- /dev/null +++ b/src/vs/platform/agentHost/test/node/providerIntegration/codexTurnDiff.integrationTest.ts @@ -0,0 +1,518 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { execFile } from 'child_process'; +import { promisify } from 'util'; +import { DeferredPromise } from '../../../../../base/common/async.js'; +import { mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join, relative } from '../../../../../base/common/path.js'; +import { isWindows } from '../../../../../base/common/platform.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { generateUuid } from '../../../../../base/common/uuid.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { AgentHostCodexAgentBinaryArgsEnvVar } from '../../../common/agentService.js'; +import { buildSessionChangesetUri, buildTurnChangesetUri } from '../../../common/changesetUri.js'; +import { CodexSessionConfigKey } from '../../../common/codexSessionConfigKeys.js'; +import { ContentEncoding } from '../../../common/state/protocol/common/commands.js'; +import type { ResourceReadResult, 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 { getToolFileEdits, MessageKind, PendingMessageKind, ResponsePartKind, ROOT_STATE_URI, type ChangesetState, type ChatState, type ISessionFileDiff } from '../../../common/state/sessionState.js'; +import { CODEX_SDK_ROOT } from '../e2e/providers/codexTestConfiguration.js'; +import { preserveAgentHostE2ELogs } from '../e2e/harness/agentHostE2EDiagnostics.js'; +import { dispatchTurn } from '../providerIntegrationTestHelpers.js'; +import { defaultChatChannel, getActionEnvelope, isActionNotification, startRealServer, stopServer, TestProtocolClient, type IServerHandle } from '../serverIntegrationTestHelpers.js'; + +suite('Agent Host Provider Integration — Codex Turn Diffs', function () { + this.timeout(120_000); + ensureNoDisposablesAreLeakedInTestSuite(); + + let root: string | undefined; + let workspace: string; + let server: IServerHandle | undefined; + let client: TestProtocolClient; + let session: string; + let options: Parameters[0]; + let clientSeq: number; + let releaseResponse: DeferredPromise | undefined; + // /rename exercises host-local history on Windows without depending on PTY command completion. + const localCommand = '/rename kept-local-command'; + + setup(function () { + if (!CODEX_SDK_ROOT) { + this.skip(); + } + clientSeq = 0; + }); + + teardown(async function () { + releaseResponse?.complete(); + releaseResponse = undefined; + if (root && this.currentTest?.state === 'failed') { + const destination = join(process.cwd(), '.build', 'logs', 'integration-tests', `codex-turn-diff-${process.pid}`); + preserveAgentHostE2ELogs(join(root, 'data'), join(root, 'home'), destination, this.currentTest.fullTitle()); + await mkdir(destination, { recursive: true }); + await writeFile(join(destination, 'protocol.json'), JSON.stringify(client?.receivedNotifications(), null, 2)); + await writeFile(join(destination, 'model.log'), server?.mockLlm?.logMessages.join('\n') ?? ''); + } + client?.close(); + try { + if (server) { + await stopServer(server); + server = undefined; + } + } finally { + if (root) { + await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); + root = undefined; + } + } + }); + + async function connect(): Promise { + client = new TestProtocolClient(server!.port); + await client.connect(); + await client.call('initialize', { channel: ROOT_STATE_URI, protocolVersions: [PROTOCOL_VERSION], clientId: 'codex-turn-diff' }, 30_000); + await client.call('authenticate', { channel: ROOT_STATE_URI, resource: 'https://api.github.com', token: 'not-a-real-token' }, 30_000); + } + + async function start(initialFiles: Record, scenarios: Record, settings: { + tool?: 'native' | 'shell'; + pausedScenario?: string; + cwdSubfolder?: string; + extraScenarios?: NonNullable[0]['mockScenarios']>; + } = {}): Promise { + const { tool = 'native', pausedScenario, extraScenarios = [] } = settings; + if (pausedScenario) { + releaseResponse = new DeferredPromise(); + } + root = await realpath(await mkdtemp(join(tmpdir(), 'codex-turn-diff-'))); + const home = join(root, 'home'); + workspace = join(root, 'workspace', settings.cwdSubfolder ?? ''); + await Promise.all([mkdir(home), mkdir(workspace, { recursive: true })]); + await Promise.all(Object.entries(initialFiles).map(([name, contents]) => writeFile(join(workspace, name), contents))); + // Give the synthetic model native patch support independently of Codex's bundled model catalog. + const modelCatalog = join(root, 'models.json'); + await writeFile(modelCatalog, JSON.stringify({ + models: [{ + slug: 'gpt-5.3-codex', display_name: 'Mock Codex', supported_reasoning_levels: [], + shell_type: 'unified_exec', visibility: 'list', supported_in_api: true, priority: 0, + support_verbosity: false, apply_patch_tool_type: 'freeform', + base_instructions: 'Apply the requested patches.', + truncation_policy: { mode: 'bytes', limit: 10000 }, experimental_supported_tools: [], + }] + })); + options = { + homeDir: home, userDataDir: join(root, 'data'), codexSdkRoot: CODEX_SDK_ROOT, mockLlm: true, + env: { [AgentHostCodexAgentBinaryArgsEnvVar]: JSON.stringify(['-c', 'features.plugins=false', '-c', 'features.unified_exec=true', '-c', 'features.multi_agent=true', '-c', `model_catalog_json=${JSON.stringify(modelCatalog)}`]) }, + mockScenarios: [...Object.entries(scenarios).map(([id, patches]) => ({ + id, + definition: { + type: 'multi-turn', + turns: [ + ...patches.map(patch => ({ + kind: 'tool-calls', + toolCalls: [tool === 'native' + ? { kind: 'custom', toolNamePattern: /^apply_patch$/, input: patch } + : { toolNamePattern: /^exec_command$/, arguments: { cmd: `apply_patch <<'PATCH'\n${patch}\nPATCH\n`, workdir: workspace, login: false } }], + })), + { + kind: 'content', chunks: pausedScenario === id + ? [{ content: 'Waiting for the test.', delayMs: 0 }, { content: 'Done.', delayMs: 0, waitFor: releaseResponse!.p }] + : [{ content: 'Done.', delayMs: 0 }] + }, + ], + }, + })), ...extraScenarios], + }; + server = await startRealServer(options); + await connect(); + session = URI.from({ scheme: 'codex', path: `/${generateUuid()}` }).toString(); + await client.call('createSession', { + channel: session, provider: 'codex', workingDirectories: [URI.file(workspace).toString()], + config: { isolation: 'folder', [CodexSessionConfigKey.PermissionsPreset]: 'full-access' }, + activeClient: { clientId: 'codex-turn-diff', tools: [], customizations: [] }, + }, 30_000); + await client.call('subscribe', { channel: session }); + await client.call('subscribe', { channel: defaultChatChannel(session) }); + } + + async function runTurn(scenario: string): Promise { + client.clearReceived(); + const turnId = generateUuid(); + dispatchTurn(client, session, turnId, `[scenario:${scenario}] Apply the patches.`, ++clientSeq); + await waitForTurn(turnId); + return turnId; + } + + async function waitForTurn(turnId: string): Promise { + await client.waitForNotification(notification => { + if (!isActionNotification(notification, 'chat/turnComplete')) { + return false; + } + const envelope = getActionEnvelope(notification); + return envelope.channel === defaultChatChannel(session) && envelope.action.type === ActionType.ChatTurnComplete && envelope.action.turnId === turnId; + }, 60_000); + assert.deepStrictEqual(client.receivedNotifications(notification => isActionNotification(notification, 'chat/error')), []); + } + + async function beginPausedTurn(scenario: string): Promise { + const turnId = generateUuid(); + dispatchTurn(client, session, turnId, `[scenario:${scenario}] Apply the patches.`, ++clientSeq); + await client.waitForNotification(notification => { + if (!isActionNotification(notification, ActionType.ChatDelta)) { + return false; + } + const { action } = getActionEnvelope(notification); + return action.type === ActionType.ChatDelta && action.turnId === turnId && action.content.includes('Waiting'); + }, 30_000); + return turnId; + } + + async function restart(): Promise { + client.close(); + await stopServer(server!); + server = await startRealServer(options); + await connect(); + await client.call('subscribe', { channel: session }); + const result = await client.call('subscribe', { channel: defaultChatChannel(session) }); + clientSeq = 0; + return result.snapshot!.state as ChatState; + } + + /** Observes the real child conversation after its native patch completed and its final response is paused. */ + async function waitForPausedChild(): Promise { + const added = getActionEnvelope(await client.waitForNotification(notification => isActionNotification(notification, ActionType.SessionChatAdded))).action; + assert.ok(added.type === ActionType.SessionChatAdded); + const child = await client.call('subscribe', { channel: added.summary.resource }); + const childState = child.snapshot!.state as ChatState; + if (!childState.activeTurn?.responseParts.some(part => part.kind === ResponsePartKind.Markdown && part.content.includes('Waiting'))) { + await client.waitForNotification(notification => { + if (!isActionNotification(notification, ActionType.ChatDelta)) { return false; } + const { action } = getActionEnvelope(notification); + return action.type === ActionType.ChatDelta && action.content.includes('Waiting'); + }, 30_000); + } + } + + async function truncate(turnId?: string): Promise { + await changes(); + client.clearReceived(); + client.dispatch({ channel: defaultChatChannel(session), clientSeq: ++clientSeq, action: { type: ActionType.ChatTruncated, turnId } }); + await client.waitForNotification(notification => { + if (!isActionNotification(notification, ActionType.ChangesetStatusChanged)) { + return false; + } + const envelope = getActionEnvelope(notification); + return envelope.channel === buildSessionChangesetUri(session) && envelope.action.type === ActionType.ChangesetStatusChanged && envelope.action.status === 'ready'; + }, 30_000); + } + + async function changes(turnId?: string) { + const channel = turnId === undefined ? buildSessionChangesetUri(session) : buildTurnChangesetUri(defaultChatChannel(session), turnId); + let subscribed = await client.call('subscribe', { channel }); + let state = subscribed.snapshot!.state as ChangesetState; + while (state.status === 'computing' || state.status === 'recomputing') { + const fromSeq = subscribed.snapshot!.fromSeq; + await client.waitForNotification(notification => isActionNotification(notification, 'changeset/statusChanged') + && getActionEnvelope(notification).channel === channel && getActionEnvelope(notification).serverSeq > fromSeq, 30_000); + subscribed = await client.call('subscribe', { channel }); + state = subscribed.snapshot!.state as ChangesetState; + } + assert.strictEqual(state.status, 'ready'); + const read = async (side: NonNullable) => { + const result = await client.call('resourceRead', { channel: ROOT_STATE_URI, uri: side.content.uri, encoding: ContentEncoding.Utf8 }); + return { path: relative(workspace, URI.parse(side.uri).fsPath), content: result.data }; + }; + const files = await Promise.all(state.files.map(async ({ edit }) => ({ + before: edit.before && await read(edit.before), + after: edit.after && await read(edit.after), + }))); + return files.sort((a, b) => (a.after?.path ?? a.before!.path).localeCompare(b.after?.path ?? b.before!.path)); + } + + test('native patches retain the cumulative turn diff across later turns and a host restart', async () => { + await start({ 'edit.txt': 'before\n', 'untouched.txt': 'unrelated\n' }, { + 'codex-diff-first': [ + '*** Begin Patch\n*** Update File: edit.txt\n@@\n-before\n+middle\n*** Add File: transient.txt\n+temporary\n*** End Patch', + '*** Begin Patch\n*** Update File: edit.txt\n@@\n-middle\n+after\n*** Delete File: transient.txt\n*** End Patch', + ], + 'codex-diff-second': ['*** Begin Patch\n*** Update File: edit.txt\n@@\n-after\n+restored\n*** End Patch'], + }); + const first = await runTurn('codex-diff-first'); + assert.strictEqual(await readFile(join(workspace, 'edit.txt'), 'utf8'), 'after\n'); + const expectedFirst = [{ before: { path: 'edit.txt', content: 'before\n' }, after: { path: 'edit.txt', content: 'after\n' } }]; + assert.deepStrictEqual(await changes(first), expectedFirst); + const toolEdits = client.receivedNotifications(notification => isActionNotification(notification, 'chat/toolCallComplete')).flatMap(notification => { + const action = getActionEnvelope(notification).action; + return action.type === ActionType.ChatToolCallComplete ? getToolFileEdits(action.result) : []; + }); + assert.deepStrictEqual(toolEdits, [], 'turn tracking must not create synthetic edit-tool pills'); + + client.close(); + await stopServer(server!); + server = await startRealServer(options); + await connect(); + await client.call('subscribe', { channel: session }); + await client.call('subscribe', { channel: defaultChatChannel(session) }); + clientSeq = 0; + const second = await runTurn('codex-diff-second'); + assert.strictEqual(await readFile(join(workspace, 'edit.txt'), 'utf8'), 'restored\n'); + assert.deepStrictEqual({ first: await changes(first), second: await changes(second) }, { + first: expectedFirst, + second: [{ before: { path: 'edit.txt', content: 'after\n' }, after: { path: 'edit.txt', content: 'restored\n' } }], + }); + }); + + test('a move over an existing destination retains both original files', async () => { + await start({ 'a.txt': 'apple\n', 'b.txt': 'banana\n' }, { + 'codex-diff-overwrite': ['*** Begin Patch\n*** Update File: a.txt\n*** Move to: b.txt\n@@\n-apple\n+pear\n*** End Patch'], + }); + const turn = await runTurn('codex-diff-overwrite'); + assert.deepStrictEqual({ files: (await readdir(workspace)).filter(name => name.endsWith('.txt')).sort(), destination: await readFile(join(workspace, 'b.txt'), 'utf8') }, { files: ['b.txt'], destination: 'pear\n' }); + assert.deepStrictEqual(await changes(turn), [ + { before: { path: 'a.txt', content: 'apple\n' }, after: undefined }, + { before: { path: 'b.txt', content: 'banana\n' }, after: { path: 'b.txt', content: 'pear\n' } }, + ]); + }); + + test('a move that recreates an earlier source tracks every physical path', async () => { + await start({ 'a.txt': 'apple\n', 'c.txt': 'cherry\n' }, { + 'codex-diff-recreate': ['*** Begin Patch\n*** Update File: a.txt\n*** Move to: b.txt\n@@\n-apple\n+pear\n*** Update File: c.txt\n*** Move to: a.txt\n@@\n-cherry\n+replacement\n*** End Patch'], + }); + const turn = await runTurn('codex-diff-recreate'); + assert.deepStrictEqual({ files: (await readdir(workspace)).filter(name => name.endsWith('.txt')).sort(), a: await readFile(join(workspace, 'a.txt'), 'utf8'), b: await readFile(join(workspace, 'b.txt'), 'utf8') }, { files: ['a.txt', 'b.txt'], a: 'replacement\n', b: 'pear\n' }); + assert.deepStrictEqual(await changes(turn), [ + { before: { path: 'a.txt', content: 'apple\n' }, after: { path: 'a.txt', content: 'replacement\n' } }, + { before: undefined, after: { path: 'b.txt', content: 'pear\n' } }, + { before: { path: 'c.txt', content: 'cherry\n' }, after: undefined }, + ]); + }); + + // This fixture deliberately exercises a Bash heredoc; native patch coverage remains enabled on Windows. + (isWindows ? test.skip : test)('shell-intercepted patches use the same turn-diff tracking', async () => { + await start({ 'edit.txt': 'before\n' }, { + 'codex-diff-shell': ['*** Begin Patch\n*** Update File: edit.txt\n@@\n-before\n+after\n*** End Patch'], + }, { tool: 'shell' }); + const turn = await runTurn('codex-diff-shell'); + assert.strictEqual(await readFile(join(workspace, 'edit.txt'), 'utf8'), 'after\n'); + assert.deepStrictEqual(await changes(turn), [{ before: { path: 'edit.txt', content: 'before\n' }, after: { path: 'edit.txt', content: 'after\n' } }]); + }); + + test('reverting every edit leaves an empty turn changeset', async () => { + await start({ 'edit.txt': 'before\n' }, { + 'codex-diff-revert': [ + '*** Begin Patch\n*** Update File: edit.txt\n@@\n-before\n+after\n*** End Patch', + '*** Begin Patch\n*** Update File: edit.txt\n@@\n-after\n+before\n*** End Patch', + ], + }); + const turn = await runTurn('codex-diff-revert'); + assert.strictEqual(await readFile(join(workspace, 'edit.txt'), 'utf8'), 'before\n'); + assert.deepStrictEqual(await changes(turn), []); + }); + + test('truncating a chat removes its persisted turn and session diffs', async () => { + await start({ 'edit.txt': 'before\n' }, { + 'codex-diff-truncate': ['*** Begin Patch\n*** Update File: edit.txt\n@@\n-before\n+after\n*** End Patch'], + }); + const turn = await runTurn('codex-diff-truncate'); + assert.strictEqual(await readFile(join(workspace, 'edit.txt'), 'utf8'), 'after\n'); + assert.deepStrictEqual(await changes(), [{ before: { path: 'edit.txt', content: 'before\n' }, after: { path: 'edit.txt', content: 'after\n' } }]); + client.clearReceived(); + client.dispatch({ channel: defaultChatChannel(session), clientSeq: ++clientSeq, action: { type: ActionType.ChatTruncated, turnId: undefined } }); + await client.waitForNotification(notification => { + if (!isActionNotification(notification, 'changeset/statusChanged')) { + return false; + } + const envelope = getActionEnvelope(notification); + return envelope.channel === buildSessionChangesetUri(session) && envelope.action.type === ActionType.ChangesetStatusChanged && envelope.action.status === 'ready'; + }, 30_000); + assert.deepStrictEqual({ session: await changes(), turn: await changes(turn) }, { session: [], turn: [] }); + }); + + test('steering splits cumulative edits into independent host checkpoints', async () => { + await start({ 'edit.txt': 'a\n' }, { + 'codex-diff-steer-first': ['*** Begin Patch\n*** Update File: edit.txt\n@@\n-a\n+b\n*** End Patch'], + 'codex-diff-steer-second': ['*** Begin Patch\n*** Update File: edit.txt\n@@\n-b\n+c\n*** End Patch'], + }, { pausedScenario: 'codex-diff-steer-first' }); + const first = await beginPausedTurn('codex-diff-steer-first'); + assert.strictEqual(await readFile(join(workspace, 'edit.txt'), 'utf8'), 'b\n'); + client.dispatch({ + channel: defaultChatChannel(session), clientSeq: ++clientSeq, action: { + type: ActionType.ChatPendingMessageSet, kind: PendingMessageKind.Steering, id: 'steer-edit', + message: { text: '[scenario:codex-diff-steer-second] Apply the next patch.', origin: { kind: MessageKind.User } }, + } + }); + await client.waitForNotification(notification => isActionNotification(notification, ActionType.ChatPendingMessageSet)); + releaseResponse!.complete(); + const started = await client.waitForNotification(notification => { + if (!isActionNotification(notification, ActionType.ChatTurnStarted)) { + return false; + } + const { action } = getActionEnvelope(notification); + return action.type === ActionType.ChatTurnStarted && action.queuedMessageId === 'steer-edit'; + }, 30_000); + const action = getActionEnvelope(started).action; + assert.ok(action.type === ActionType.ChatTurnStarted); + await waitForTurn(action.turnId); + assert.strictEqual(await readFile(join(workspace, 'edit.txt'), 'utf8'), 'c\n'); + assert.deepStrictEqual({ first: await changes(first), second: await changes(action.turnId) }, { + first: [{ before: { path: 'edit.txt', content: 'a\n' }, after: { path: 'edit.txt', content: 'b\n' } }], + second: [{ before: { path: 'edit.txt', content: 'b\n' }, after: { path: 'edit.txt', content: 'c\n' } }], + }); + await truncate(first); + assert.deepStrictEqual(await changes(), [{ before: { path: 'edit.txt', content: 'a\n' }, after: { path: 'edit.txt', content: 'b\n' } }]); + await restart(); + assert.deepStrictEqual(await changes(action.turnId), []); + }); + + test('cancellation exposes the edited checkpoint before native completion is consumed', async () => { + await start({ 'edit.txt': 'before\n' }, { + 'codex-diff-cancel': ['*** Begin Patch\n*** Update File: edit.txt\n@@\n-before\n+after\n*** End Patch'], + }, { pausedScenario: 'codex-diff-cancel' }); + const turn = await beginPausedTurn('codex-diff-cancel'); + assert.strictEqual(await readFile(join(workspace, 'edit.txt'), 'utf8'), 'after\n'); + await changes(); + client.dispatch({ channel: defaultChatChannel(session), clientSeq: ++clientSeq, action: { type: ActionType.ChatTurnCancelled, turnId: turn, duration: 0 } }); + await client.waitForNotification(notification => isActionNotification(notification, ActionType.ChatTurnCancelled)); + const turnChanges = await changes(turn); + await client.waitForNotification(notification => { + if (!isActionNotification(notification, ActionType.ChangesetContentChanged)) { + return false; + } + const envelope = getActionEnvelope(notification); + return envelope.channel === buildSessionChangesetUri(session) && envelope.action.type === ActionType.ChangesetContentChanged && envelope.action.files.length > 0; + }, 30_000); + assert.deepStrictEqual({ session: await changes(), turn: turnChanges }, { + session: [{ before: { path: 'edit.txt', content: 'before\n' }, after: { path: 'edit.txt', content: 'after\n' } }], + turn: [{ before: { path: 'edit.txt', content: 'before\n' }, after: { path: 'edit.txt', content: 'after\n' } }], + }); + }); + + test('truncation preserves a retained local command before the first native turn', async () => { + await start({ 'edit.txt': 'before\n' }, { + 'codex-diff-local-boundary': ['*** Begin Patch\n*** Update File: edit.txt\n@@\n-before\n+after\n*** End Patch'], + }); + const local = generateUuid(); + dispatchTurn(client, session, local, localCommand, ++clientSeq); + await waitForTurn(local); + await runTurn('codex-diff-local-boundary'); + await truncate(local); + const restored = await restart(); + assert.deepStrictEqual(restored.turns.map(turn => ({ id: turn.id, text: turn.message.text })), [{ id: local, text: localCommand }]); + assert.deepStrictEqual(restored.turns[0].responseParts.filter(part => part.kind === ResponsePartKind.Markdown).map(part => part.content), ['Renamed: kept-local-command']); + }); + + for (const reloadBeforeTruncation of [false, true]) { + test(`truncation preserves a retained local command after a native turn (reload first: ${reloadBeforeTruncation})`, async () => { + await start({ 'edit.txt': 'before\n' }, { + 'codex-diff-before-local': ['*** Begin Patch\n*** Update File: edit.txt\n@@\n-before\n+middle\n*** End Patch'], + 'codex-diff-after-local': ['*** Begin Patch\n*** Update File: edit.txt\n@@\n-middle\n+after\n*** End Patch'], + }); + await runTurn('codex-diff-before-local'); + const local = generateUuid(); + dispatchTurn(client, session, local, localCommand, ++clientSeq); + await waitForTurn(local); + if (reloadBeforeTruncation) { + const restored = await restart(); + assert.ok(restored.turns.some(turn => turn.id === local), 'the local command survives a reload before any truncation'); + } + await runTurn('codex-diff-after-local'); + await truncate(local); + const restored = await restart(); + assert.deepStrictEqual(restored.turns.map(turn => turn.message.text), ['[scenario:codex-diff-before-local] Apply the patches.', localCommand]); + assert.deepStrictEqual(restored.turns[1].responseParts.filter(part => part.kind === ResponsePartKind.Markdown).map(part => part.content), ['Renamed: kept-local-command']); + assert.deepStrictEqual(await changes(), [{ before: { path: 'edit.txt', content: 'before\n' }, after: { path: 'edit.txt', content: 'middle\n' } }]); + }); + } + + test('shared parent and child edits retain their checkpoint across cancellation and a replacement send', async () => { + await start({ 'edit.txt': 'before\n' }, { + 'codex-diff-child': ['*** Begin Patch\n*** Update File: edit.txt\n@@\n-parent edit\n+child edit\n*** End Patch'], + 'codex-diff-replacement': ['*** Begin Patch\n*** Update File: edit.txt\n@@\n-child edit\n+replacement edit\n*** End Patch'], + }, { + pausedScenario: 'codex-diff-child', + extraScenarios: [{ + id: 'codex-diff-spawn', definition: { + type: 'multi-turn', turns: [ + { kind: 'tool-calls', toolCalls: [{ kind: 'custom', toolNamePattern: /^apply_patch$/, input: '*** Begin Patch\n*** Update File: edit.txt\n@@\n-before\n+parent edit\n*** End Patch' }] }, + { kind: 'tool-calls', toolCalls: [{ toolNamePattern: /^spawn_agent$/, namespace: 'multi_agent_v1', arguments: { agent_type: 'default', message: '[scenario:codex-diff-child] Apply the patch.' } }] }, + { kind: 'content', chunks: [{ content: 'Done.', delayMs: 0 }] }, + ] + }, + }], + }); + client.clearReceived(); + const parent = generateUuid(); + dispatchTurn(client, session, parent, '[scenario:codex-diff-spawn] Apply the patches.', ++clientSeq); + await waitForPausedChild(); + assert.strictEqual(await readFile(join(workspace, 'edit.txt'), 'utf8'), 'child edit\n'); + client.dispatch({ channel: defaultChatChannel(session), clientSeq: ++clientSeq, action: { type: ActionType.ChatTurnCancelled, turnId: parent, duration: 0 } }); + await client.waitForNotification(notification => isActionNotification(notification, ActionType.ChatTurnCancelled)); + const replacement = await runTurn('codex-diff-replacement'); + assert.strictEqual(await readFile(join(workspace, 'edit.txt'), 'utf8'), 'replacement edit\n'); + const checkpoint = changes(parent); + releaseResponse!.complete(); + assert.deepStrictEqual(await checkpoint, [{ before: { path: 'edit.txt', content: 'before\n' }, after: { path: 'edit.txt', content: 'child edit\n' } }]); + assert.deepStrictEqual(await changes(replacement), [{ before: { path: 'edit.txt', content: 'child edit\n' }, after: { path: 'edit.txt', content: 'replacement edit\n' } }]); + assert.deepStrictEqual(await changes(), [{ before: { path: 'edit.txt', content: 'before\n' }, after: { path: 'edit.txt', content: 'replacement edit\n' } }]); + }); + + test('a child editing first supplies the original when its parent edits the same path later', async () => { + await start({ 'edit.txt': 'before\n' }, { + 'codex-diff-child-first': ['*** Begin Patch\n*** Update File: edit.txt\n@@\n-before\n+child first\n*** End Patch'], + }, { + pausedScenario: 'codex-diff-child-first', + extraScenarios: [{ + id: 'codex-diff-parent-last', definition: { + type: 'multi-turn', turns: [ + { kind: 'tool-calls', toolCalls: [{ toolNamePattern: /^spawn_agent$/, namespace: 'multi_agent_v1', arguments: { agent_type: 'default', message: '[scenario:codex-diff-child-first] Apply the patch.' } }] }, + { + kind: 'tool-calls', toolCalls: [{ + toolNamePattern: /^exec_command$/, arguments: { + cmd: `node -e "const fs=require('fs');const timer=setInterval(()=>{if(fs.readFileSync('edit.txt','utf8').trim()==='child first'){clearInterval(timer);process.exit(0);}},10);setTimeout(()=>process.exit(1),10000)"`, login: false, + } + }] + }, + { kind: 'tool-calls', toolCalls: [{ kind: 'custom', toolNamePattern: /^apply_patch$/, input: '*** Begin Patch\n*** Update File: edit.txt\n@@\n-child first\n+parent last\n*** End Patch' }] }, + { kind: 'content', chunks: [{ content: 'Done.', delayMs: 0 }] }, + ] + } + }], + }); + const parent = await runTurn('codex-diff-parent-last'); + await waitForPausedChild(); + assert.strictEqual(await readFile(join(workspace, 'edit.txt'), 'utf8'), 'parent last\n'); + const checkpoint = changes(parent); + releaseResponse!.complete(); + assert.deepStrictEqual(await checkpoint, [{ before: { path: 'edit.txt', content: 'before\n' }, after: { path: 'edit.txt', content: 'parent last\n' } }]); + }); + + test('a working directory below the repository root preserves native display paths', async () => { + await start({ 'edit.txt': 'before\n' }, { + 'codex-diff-subfolder': ['*** Begin Patch\n*** Update File: edit.txt\n@@\n-before\n+after\n*** End Patch'], + }, { cwdSubfolder: 'nested' }); + await promisify(execFile)('git', ['init', join(root!, 'workspace')]); + const turn = await runTurn('codex-diff-subfolder'); + assert.strictEqual(await readFile(join(workspace, 'edit.txt'), 'utf8'), 'after\n'); + assert.deepStrictEqual(await changes(turn), [{ before: { path: 'edit.txt', content: 'before\n' }, after: { path: 'edit.txt', content: 'after\n' } }]); + }); + + test('partial truncation after restart removes discarded native snapshots', async () => { + await start({ 'edit.txt': 'before\n' }, { + 'codex-diff-partial-first': ['*** Begin Patch\n*** Update File: edit.txt\n@@\n-before\n+middle\n*** End Patch'], + 'codex-diff-partial-second': ['*** Begin Patch\n*** Update File: edit.txt\n@@\n-middle\n+after\n*** End Patch'], + }); + await runTurn('codex-diff-partial-first'); + await runTurn('codex-diff-partial-second'); + assert.strictEqual(await readFile(join(workspace, 'edit.txt'), 'utf8'), 'after\n'); + const restored = await restart(); + assert.strictEqual(restored.turns.length, 2); + await truncate(restored.turns[0].id); + assert.deepStrictEqual(await changes(), [{ before: { path: 'edit.txt', content: 'before\n' }, after: { path: 'edit.txt', content: 'middle\n' } }]); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/providerIntegration/copilotConnectors.integrationTest.ts b/src/vs/platform/agentHost/test/node/providerIntegration/copilotConnectors.integrationTest.ts index 316c59c0ba112f..6b0a7367bceed8 100644 --- a/src/vs/platform/agentHost/test/node/providerIntegration/copilotConnectors.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/providerIntegration/copilotConnectors.integrationTest.ts @@ -198,7 +198,7 @@ suite('Copilot SDK Connector reconciliation', function () { assert.ok(mail && calendar); assert.deepStrictEqual({ reconciled: targeted.reconciled, initializations, - name: targeted.displayNames?.get(mail.runtimeServerId), + name: targeted.connectorServers?.servers.get(mail.runtimeServerId)?.displayName, tools: (await session.rpc.mcp.listTools({ serverName: mail.runtimeServerId })).tools.map(tool => tool.name), sdkSessionId: session.sessionId, }, { diff --git a/src/vs/platform/agentHost/test/node/providerIntegration/copilotMockLlm.integrationTest.ts b/src/vs/platform/agentHost/test/node/providerIntegration/copilotMockLlm.integrationTest.ts index db313e29fa4db4..c91c242934998e 100644 --- a/src/vs/platform/agentHost/test/node/providerIntegration/copilotMockLlm.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/providerIntegration/copilotMockLlm.integrationTest.ts @@ -15,7 +15,7 @@ import { retry, timeout } from '../../../../../base/common/async.js'; import { join } from '../../../../../base/common/path.js'; import { isWindows } from '../../../../../base/common/platform.js'; import { URI } from '../../../../../base/common/uri.js'; -import { AgentHostCanvasesEnabledConfigKey } from '../../../common/agentHostSchema.js'; +import { AgentHostCanvasesEnabledConfigKey, AgentHostWorkspaceTrustConfigKey } from '../../../common/agentHostSchema.js'; import { hasKey } from '../../../../../base/common/types.js'; import { ActionType, type ChatResponsePartAction, type ChatToolCallCompleteAction, type ChatToolCallReadyAction, type ChatToolCallStartAction, type ChatTurnCompleteAction, type ChatTurnStartedAction } from '../../../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../../../common/state/protocol/version/registry.js'; @@ -45,6 +45,7 @@ const STEERING_RESPONSE_DELAY_MS = 10_000; const CANVAS_SCENARIO_ID = 'model-opened-canvas'; const CANVAS_EXTENSION_TURN_SCENARIO_ID = 'canvas-extension-turn'; const PLUGIN_SIBLING_SCENARIO_ID = 'plugin-sibling-delegation'; +const PLUGIN_RESTRICTED_SIBLING_SCENARIO_ID = 'plugin-restricted-sibling-delegation'; function quoteShellArgument(value: string): string { return isWindows ? `'${value.replace(/'/g, '\'\'')}'` : `'${value.replace(/'/g, `'\\''`)}'`; @@ -132,6 +133,26 @@ suite('Agent Host Provider Integration — Copilot with Mock LLM', function () { { kind: 'content', chunks: [{ content: 'PLUGIN_PARENT_DONE', delayMs: 0 }] }, ], }, + }, { + id: PLUGIN_RESTRICTED_SIBLING_SCENARIO_ID, + definition: { + type: 'multi-turn', + turns: [ + { + kind: 'tool-calls', + toolCalls: [{ + toolNamePattern: /^task$/, + arguments: { + name: 'plugin-restricted', + description: 'Run the restricted plugin sibling', + prompt: 'Complete the restricted delegated task.', + agent_type: 'plugin-restricted', + }, + }], + }, + { kind: 'content', chunks: [{ content: 'PLUGIN_RESTRICTED_DONE', delayMs: 0 }] }, + ], + }, }], }); }); @@ -197,6 +218,7 @@ suite('Agent Host Provider Integration — Copilot with Mock LLM', function () { const pluginDir = join(workspaceDir, 'plugin'); const parentAgentFile = join(pluginDir, 'agents', 'parent.agent.md'); const siblingAgentFile = join(pluginDir, 'agents', 'sibling.agent.md'); + const restrictedAgentFile = join(pluginDir, 'agents', 'restricted.agent.md'); await Promise.all([ mkdir(join(pluginDir, '.plugin'), { recursive: true }), mkdir(join(pluginDir, 'agents'), { recursive: true }), @@ -205,6 +227,7 @@ suite('Agent Host Provider Integration — Copilot with Mock LLM', function () { writeFile(join(pluginDir, '.plugin', 'plugin.json'), JSON.stringify({ name: 'Plugin Delegation' })), writeFile(parentAgentFile, '---\nname: plugin-parent\ndescription: Delegates plugin work\n---\nDelegate work to plugin-sibling.'), writeFile(siblingAgentFile, '---\nname: plugin-sibling\ndescription: Completes plugin work\n---\nComplete the delegated task.'), + writeFile(restrictedAgentFile, '---\nname: plugin-restricted\ndescription: Only runs when selected by the user\ninfer: false\n---\nComplete the restricted task.'), ]); const clientId = 'real-sdk-plugin-subagents'; @@ -230,62 +253,75 @@ suite('Agent Host Provider Integration — Copilot with Mock LLM', function () { && getActionEnvelope(notification).channel === sessionUri, 30_000, ); - await retry(async () => { + const parentAgentUri = await retry(async () => { const session = await fetchSessionWithChat(client, sessionUri); const plugin = session.customizations?.find((customization): customization is PluginCustomization => customization.type === CustomizationType.Plugin && customization.uri === pluginUri ); - assert.deepStrictEqual(plugin?.children?.map(child => child.name).sort(), ['plugin-parent', 'plugin-sibling']); + assert.deepStrictEqual(plugin?.children?.map(child => child.name).sort(), ['plugin-parent', 'plugin-restricted', 'plugin-sibling']); + const parent = plugin?.children?.find(child => child.name === 'plugin-parent'); + assert.ok(parent); + return parent.uri; }, 100, 300); - client.clearReceived(); const chatUri = defaultChatChannel(sessionUri); - const taskStarted = client.waitForNotification(notification => - isActionNotification(notification, ActionType.ChatToolCallStart) - && getActionEnvelope(notification).channel === chatUri - && (getActionEnvelope(notification).action as ChatToolCallStartAction).toolName === 'task', - 90_000, - ); - client.dispatch({ - channel: chatUri, - clientSeq: 2, - action: { - type: ActionType.ChatTurnStarted, - turnId: 'turn-plugin-subagent', - startedAt: new Date().toISOString(), - message: { - text: `[scenario:${PLUGIN_SIBLING_SCENARIO_ID}] Delegate to the plugin sibling.`, - origin: { kind: MessageKind.User }, - agent: { uri: URI.file(parentAgentFile).toString() }, + const delegate = async (clientSeq: number, turnId: string, scenarioId: string) => { + client.clearReceived(); + const taskStarted = client.waitForNotification(notification => + isActionNotification(notification, ActionType.ChatToolCallStart) + && getActionEnvelope(notification).channel === chatUri + && (getActionEnvelope(notification).action as ChatToolCallStartAction).toolName === 'task', + 90_000, + ); + client.dispatch({ + channel: chatUri, + clientSeq, + action: { + type: ActionType.ChatTurnStarted, + turnId, + startedAt: new Date().toISOString(), + message: { + text: `[scenario:${scenarioId}] Delegate to the plugin sibling.`, + origin: { kind: MessageKind.User }, + agent: { uri: parentAgentUri }, + }, }, - }, - }); - const startAction = getActionEnvelope(await taskStarted).action as ChatToolCallStartAction; - const completeNotification = await client.waitForNotification(notification => - isActionNotification(notification, ActionType.ChatToolCallComplete) - && getActionEnvelope(notification).channel === chatUri - && (getActionEnvelope(notification).action as ChatToolCallCompleteAction).toolCallId === startAction.toolCallId, - 90_000, - ); - await client.waitForNotification(notification => - isActionNotification(notification, ActionType.ChatTurnComplete) - && getActionEnvelope(notification).channel === chatUri - && (getActionEnvelope(notification).action as ChatTurnCompleteAction).turnId === 'turn-plugin-subagent', - 90_000, - ); + }); + const startAction = getActionEnvelope(await taskStarted).action as ChatToolCallStartAction; + const completeNotification = await client.waitForNotification(notification => + isActionNotification(notification, ActionType.ChatToolCallComplete) + && getActionEnvelope(notification).channel === chatUri + && (getActionEnvelope(notification).action as ChatToolCallCompleteAction).toolCallId === startAction.toolCallId, + 90_000, + ); + await client.waitForNotification(notification => + isActionNotification(notification, ActionType.ChatTurnComplete) + && getActionEnvelope(notification).channel === chatUri + && (getActionEnvelope(notification).action as ChatTurnCompleteAction).turnId === turnId, + 90_000, + ); + + const completeAction = getActionEnvelope(completeNotification).action as ChatToolCallCompleteAction; + const session = await fetchSessionWithChat(client, sessionUri); + const response = session.turns.find(turn => turn.id === turnId)?.responseParts + .filter(part => part.kind === ResponsePartKind.Markdown) + .map(part => part.content) + .join(''); + return { taskSucceeded: completeAction.result.success, parentResponse: response }; + }; - const completeAction = getActionEnvelope(completeNotification).action as ChatToolCallCompleteAction; - const session = await fetchSessionWithChat(client, sessionUri); - const response = session.turns.find(turn => turn.id === 'turn-plugin-subagent')?.responseParts - .filter(part => part.kind === ResponsePartKind.Markdown) - .map(part => part.content) - .join(''); assert.deepStrictEqual({ - taskSucceeded: completeAction.result.success, - parentResponse: response, + modelInvocable: await delegate(2, 'turn-plugin-subagent', PLUGIN_SIBLING_SCENARIO_ID), + modelRestricted: await delegate(3, 'turn-plugin-restricted-subagent', PLUGIN_RESTRICTED_SIBLING_SCENARIO_ID), }, { - taskSucceeded: true, - parentResponse: 'PLUGIN_PARENT_DONE', + modelInvocable: { + taskSucceeded: true, + parentResponse: 'PLUGIN_PARENT_DONE', + }, + modelRestricted: { + taskSucceeded: false, + parentResponse: 'PLUGIN_RESTRICTED_DONE', + }, }); }); @@ -328,11 +364,11 @@ session = await joinSession({ }); `, 'utf8'); - const setCanvasesEnabled = async (enabled: boolean, clientSeq: number) => { + const setRootConfig = async (config: Record, clientSeq: number, replace = false) => { client.dispatch({ channel: ROOT_STATE_URI, clientSeq, - action: { type: ActionType.RootConfigChanged, config: { [AgentHostCanvasesEnabledConfigKey]: enabled } }, + action: { type: ActionType.RootConfigChanged, config, ...(replace ? { replace } : {}) }, }); await client.waitForNotification(n => isActionNotification(n, ActionType.RootConfigChanged) @@ -340,6 +376,7 @@ session = await joinSession({ ); }; let previousCanvasesEnabled: boolean | undefined; + let previousWorkspaceTrust: unknown; try { const sessionUri = await createProviderSession( client, @@ -349,8 +386,13 @@ session = await joinSession({ URI.file(workspaceDir), async () => { const result = await client.call('subscribe', { channel: ROOT_STATE_URI }); - previousCanvasesEnabled = (result.snapshot!.state as RootState).config?.values[AgentHostCanvasesEnabledConfigKey] === true; - await setCanvasesEnabled(true, 1); + const rootConfig = (result.snapshot!.state as RootState).config?.values; + previousCanvasesEnabled = rootConfig?.[AgentHostCanvasesEnabledConfigKey] === true; + previousWorkspaceTrust = rootConfig?.[AgentHostWorkspaceTrustConfigKey]; + await setRootConfig({ + [AgentHostCanvasesEnabledConfigKey]: true, + [AgentHostWorkspaceTrustConfigKey]: { enabled: true, trustedUris: [URI.file(workspaceDir).toString()] }, + }, 1); }, ); dispatchTurn(client, sessionUri, 'turn-mock-canvas', `[scenario:${CANVAS_SCENARIO_ID}] Open the available proof canvas.`, 2); @@ -433,7 +475,15 @@ session = await joinSession({ }); } finally { if (previousCanvasesEnabled !== undefined) { - await setCanvasesEnabled(previousCanvasesEnabled, 3); + const currentState = (await client.call('subscribe', { channel: ROOT_STATE_URI })).snapshot!.state as RootState; + const config = { ...currentState.config?.values }; + config[AgentHostCanvasesEnabledConfigKey] = previousCanvasesEnabled; + if (previousWorkspaceTrust === undefined) { + delete config[AgentHostWorkspaceTrustConfigKey]; + } else { + config[AgentHostWorkspaceTrustConfigKey] = previousWorkspaceTrust; + } + await setRootConfig(config, 3, true); } } }); diff --git a/src/vs/platform/agentHost/test/node/scheduledChatMoves.test.ts b/src/vs/platform/agentHost/test/node/scheduledChatMoves.test.ts new file mode 100644 index 00000000000000..63a83684401347 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/scheduledChatMoves.test.ts @@ -0,0 +1,118 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { AgentChatMoveRefusal, AgentChatMoveRefusedError } from '../../common/agentHostChatMove.js'; +import { SessionStatus } from '../../common/state/protocol/channels-session/state.js'; +import { buildDefaultChatUri } from '../../common/state/sessionState.js'; +import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; +import { AgentHostScheduledChatMoves } from '../../node/chatContributions/scheduledChatMoves/scheduledChatMovesService.js'; +import type { ISessionServerToolMove } from '../../node/shared/sessionServerTools.js'; + +suite('AgentHostScheduledChatMoves', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + const session = 'copilot:/s1'; + const chat = buildDefaultChatUri(session); + const move: ISessionServerToolMove = { kind: 'session', session: URI.parse(session), destination: URI.parse('copilot:/s2'), after: 'end' }; + + function create(apply: (move: ISessionServerToolMove) => Promise = async () => URI.parse('copilot:/s2')) { + const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + const applied: string[] = []; + const moves = disposables.add(new AgentHostScheduledChatMoves({ + applyChatMove: scheduled => { + applied.push(scheduled.session.toString()); + return apply(scheduled); + }, + }, stateManager, new NullLogService())); + return { stateManager, moves, applied }; + } + + function createSession(stateManager: AgentHostStateManager): void { + stateManager.createSession({ + resource: session, + provider: 'copilot', + title: 'Session', + status: SessionStatus.Idle, + createdAt: new Date(0).toISOString(), + modifiedAt: new Date(0).toISOString(), + defaultChat: chat, + }); + } + + test('applies a scheduled move once, when the turn that requested it succeeds', () => { + const { moves, applied } = create(); + moves.schedule(chat, 'turn-1', move); + moves.turnEnded(chat, 'turn-0', true); + const beforeItsTurnEnded = [...applied]; + moves.turnEnded(chat, 'turn-1', true); + moves.turnEnded(chat, 'turn-1', true); + + assert.deepStrictEqual({ beforeItsTurnEnded, applied }, { beforeItsTurnEnded: [], applied: [session] }); + }); + + test('holds the chat\'s queued turns from its turn end until the move is done, also when it is refused', async () => { + const moving = new DeferredPromise(); + const { stateManager, moves } = create(() => moving.p); + const released: string[][] = []; + disposables.add(stateManager.onDidReleaseChatAdmission(chats => released.push([...chats]))); + moves.schedule(chat, 'turn-1', move); + const beforeTurnEnd = stateManager.isChatAdmissionHeld(chat); + moves.turnEnded(chat, 'turn-1', true); + // Synchronous: a queue drain later in the same turn end already sees the hold. + const atTurnEnd = stateManager.isChatAdmissionHeld(chat); + await timeout(0); + const whileMoving = stateManager.isChatAdmissionHeld(chat); + moving.complete(URI.parse('copilot:/s2')); + await timeout(0); + const moved = { held: stateManager.isChatAdmissionHeld(chat), released: [...released] }; + + const refused = create(async () => { throw new AgentChatMoveRefusedError(AgentChatMoveRefusal.Conflict, 'changed'); }); + refused.moves.schedule(chat, 'turn-2', move); + refused.moves.turnEnded(chat, 'turn-2', true); + await timeout(0); + + assert.deepStrictEqual({ beforeTurnEnd, atTurnEnd, whileMoving, moved, heldAfterRefusal: refused.stateManager.isChatAdmissionHeld(chat) }, { + beforeTurnEnd: false, + atTurnEnd: true, + whileMoving: true, + moved: { held: false, released: [[chat]] }, + heldAfterRefusal: false, + }); + }); + + test('drops a move whose turn did not succeed, and refuses a second move in one turn', () => { + const { stateManager, moves, applied } = create(); + moves.schedule(chat, 'cancelled', move); + moves.turnEnded(chat, 'cancelled', false); + moves.turnEnded(chat, 'cancelled', true); + + moves.schedule(chat, 'twice', move); + let second: string | undefined; + try { + moves.schedule(chat, 'twice', move); + } catch (error) { + second = (error as Error).message; + } + + assert.deepStrictEqual({ applied, second, held: stateManager.isChatAdmissionHeld(chat) }, { applied: [], second: 'A move of this chat is already scheduled for when this turn ends.', held: false }); + }); + + test('clears a scheduled move when its chat is unregistered', () => { + const { stateManager, moves, applied } = create(); + createSession(stateManager); + moves.schedule(chat, 'deleted', move); + // Deleting the session unregisters its chats. + stateManager.deleteSession(session); + moves.turnEnded(chat, 'deleted', true); + + assert.deepStrictEqual({ applied, held: stateManager.isChatAdmissionHeld(chat) }, { applied: [], held: false }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/serverToolGroups.test.ts b/src/vs/platform/agentHost/test/node/serverToolGroups.test.ts index 319da0c1365322..e29edfa10e233e 100644 --- a/src/vs/platform/agentHost/test/node/serverToolGroups.test.ts +++ b/src/vs/platform/agentHost/test/node/serverToolGroups.test.ts @@ -6,6 +6,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import type { StringOrMarkdown } from '../../common/state/sessionState.js'; +import { SessionServerToolName } from '../../common/serverToolNames.js'; import { buildServerToolGroups, getServerToolDisplay } from '../../node/shared/serverToolGroups.js'; function text(value: StringOrMarkdown | undefined): string | undefined { @@ -146,4 +147,15 @@ suite('serverToolGroups deferral budget', () => { 'add_artifact_or_reference', ]); }); + + test('move_session is in neither the eager nor the materialized baseline; it is displayed without a host', () => { + assert.deepStrictEqual({ + // Its own group joins only with the host's accessor and limits, and offers it only while moving chats is enabled (see sessionServerTools.test.ts). + inBaseline: definitions.some(definition => definition.name === SessionServerToolName.MoveSession), + displayName: getServerToolDisplay(SessionServerToolName.MoveSession, undefined)?.displayName, + }, { + inBaseline: false, + displayName: 'Move Session', + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts index 33de350d231726..abbdc11a053b0c 100644 --- a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts @@ -296,6 +296,41 @@ suite('SessionDatabase', () => { ); }); + test('provider truncation removes every mapped segment while preserving local and foreign turns', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + for (const [id, eventId] of [ + ['kept', 'native-kept'], ['first-segment', 'native-removed'], ['second-segment', 'native-removed'], + ['foreign', 'native-removed'], ['failed-local', 'native-removed'], ['native-direct', 'native-direct'], + ]) { + await db.setTurnEventId(id, eventId); + await db.storeFileEdit({ turnId: id, toolCallId: id, filePath: `/${id}.ts`, kind: FileEditKind.Edit, addedLines: 1, removedLines: 1 }); + } + await db.setForeignRows('chat:owner', ['foreign']); + await db.insertPersistedTurn({ kind: 'failed', turnId: 'failed-local', chatUri: 'chat:owner', anchorTurnId: undefined, payload: '{}' }); + await db.insertPersistedTurn({ kind: 'local', turnId: 'bang-command', chatUri: 'chat:owner', anchorTurnId: undefined, payload: '{}' }); + await db.deleteProviderTurns(['native-removed', 'native-direct', 'absent']); + assert.deepStrictEqual({ + edits: (await db.getAllFileEdits()).map(edit => edit.turnId), + local: (await db.getPersistedTurns()).map(turn => turn.turnId), + }, { edits: ['kept', 'foreign', 'failed-local'], local: ['failed-local', 'bang-command'] }); + }); + + test('truncating host segments of one native turn keeps local, foreign, and other native rows', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + for (const id of ['first', 'failed-local', 'steering', 'foreign', 'other-native']) { + await db.setTurnEventId(id, id === 'other-native' ? 'native-2' : 'native-1'); + await db.storeFileEdit({ turnId: id, toolCallId: id, filePath: `/${id}.ts`, kind: FileEditKind.Edit, addedLines: 1, removedLines: 1 }); + } + await db.setForeignRows('chat:owner', ['foreign']); + await db.insertPersistedTurn({ kind: 'failed', turnId: 'failed-local', chatUri: 'chat:owner', anchorTurnId: undefined, payload: '{}' }); + await db.insertPersistedTurn({ kind: 'local', turnId: 'bang', chatUri: 'chat:owner', anchorTurnId: 'first', payload: '{}' }); + await db.deleteProviderTurns([], 'first'); + assert.deepStrictEqual({ + edits: (await db.getAllFileEdits()).map(edit => edit.turnId), + local: (await db.getPersistedTurns()).map(turn => turn.turnId), + }, { edits: ['first', 'failed-local', 'foreign', 'other-native'], local: ['failed-local', 'bang'] }); + }); + test('retrieve multiple edits for a single tool call', async () => { db = disposables.add(await SessionDatabase.open(':memory:')); diff --git a/src/vs/platform/agentHost/test/node/sessionDiffAggregator.test.ts b/src/vs/platform/agentHost/test/node/sessionDiffAggregator.test.ts index ed991d5e33ed2e..2b7f11092109e4 100644 --- a/src/vs/platform/agentHost/test/node/sessionDiffAggregator.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionDiffAggregator.test.ts @@ -720,6 +720,32 @@ suite('computeUnionedDiffs', () => { part: 'after', }); }); + + test('counts the edits a forked chat copied from its parent once, so they do not hide later edits', async () => { + const copiedEdit = { + turnId: 't1', toolCallId: 'tc1', filePath: '/shared.txt', kind: FileEditKind.Edit, + addedLines: undefined, removedLines: undefined, + beforeContent: encodeString('v1'), afterContent: encodeString('v2'), + }; + const sessionDb = new TestSessionDatabase(); + sessionDb.addEdit(copiedEdit); + sessionDb.addEdit({ ...copiedEdit, turnId: 't2', toolCallId: 'tc2', beforeContent: encodeString('v2'), afterContent: encodeString('v3\nv4') }); + const forkDb = new TestSessionDatabase(); + forkDb.addEdit(copiedEdit); + + const result = await computeUnionedDiffs( + [ + { sessionUri: TEST_SESSION_URI, db: sessionDb }, + { sessionUri: PEER_CHAT_URI, db: forkDb }, + ], + createTestDiffService(), + ); + + assert.deepStrictEqual(result.map(diff => ({ ...simplify(diff), after: parseSessionDbUri(diff.after!.content.uri) })), [{ + ...simpleDiff('/shared.txt', 1, 0), + after: { sessionUri: TEST_SESSION_URI, toolCallId: 'tc2', filePath: '/shared.txt', part: 'after' }, + }]); + }); }); suite('computeTurnDiffs', () => { diff --git a/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts b/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts index 3a367f55aeb172..ad76ab68a132b7 100644 --- a/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts @@ -15,7 +15,7 @@ import { NullLogService } from '../../../log/common/log.js'; import type { IAgentCreateSessionConfig, IAgentModelInfo, IAgentSessionMetadata } from '../../common/agent.js'; import { SessionStatus } from '../../common/state/protocol/channels-session/state.js'; import { ActionType } from '../../common/state/sessionActions.js'; -import { buildChatUri, buildDefaultChatUri, MessageKind, PendingMessageKind, readSessionCreationReference, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, TurnState, withSessionGitState, withSessionGitHubState, withSessionWorkspaceless, type ModelSelection, type ResponsePart, type ToolCallState, type ToolDefinition, type Turn } from '../../common/state/sessionState.js'; +import { buildChatUri, buildDefaultChatUri, ChatInteractivity, ChatOriginKind, getAgentHostChatId, MessageKind, PendingMessageKind, readSessionCreationReference, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, TurnState, withSessionGitState, withSessionGitHubState, withSessionWorkspaceless, type ModelSelection, type ResponsePart, type ToolCallState, type ToolDefinition, type Turn } from '../../common/state/sessionState.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { AgentHostStateManager, creationTimeChatStorageResource } from '../../node/agentHostStateManager.js'; import type { AutomaticTitleGenerationStrategy } from '../../node/agentHostSessionTitleController.js'; @@ -24,6 +24,10 @@ import { withEphemeralSessionMeta } from '../../common/meta/agentEphemeralSessio import { readAgentMessageDelegationMeta, toAgentMessageDelegationMeta } from '../../common/meta/agentMessageDelegationMeta.js'; import { AgentServerToolHost, type IServerToolGroup } from '../../node/shared/agentServerToolHost.js'; import { mintedTestChatOwner } from '../common/mintedChatUri.js'; +import { AgentChatMoveRefusal, AgentChatMoveRefusedError } from '../../common/agentHostChatMove.js'; +import { buildOpenSessionLinkUri } from '../../common/openSessionLink.js'; +import { buildServerToolGroups, getServerToolDisplay } from '../../node/shared/serverToolGroups.js'; +import { createSessionMoveToolGroup, getMoveRefusalMessage, moveSessionToolDefinition } from '../../node/shared/sessionMoveTools.js'; import { applyCreateChatTool, applyCreateSessionTool, @@ -49,6 +53,8 @@ import { type IChatContextSnapshot, type IPreparedChatWorkingDirectory, type ISessionServerToolAccessor, + type ISessionServerToolMove, + type SessionServerToolMoveAnchor, } from '../../node/shared/sessionServerTools.js'; suite('SessionServerTools', () => { @@ -97,7 +103,13 @@ 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); }), + isChatMoveEnabled: overrides?.isChatMoveEnabled ?? (() => true), + supportsChatMoves: overrides?.supportsChatMoves ?? (() => true), + checkChatMove: overrides?.checkChatMove ?? (async () => { }), + isKnownSessionLink: overrides?.isKnownSessionLink ?? (() => true), + applyChatMove: overrides?.applyChatMove ?? (async move => move.kind === 'session' ? move.destination : move.destination.kind === 'session' ? move.destination.session : URI.parse('copilot:/promoted')), requestSessionWorkspaceUpdate: overrides?.requestSessionWorkspaceUpdate ?? (() => true), + scheduleChatMove: overrides?.scheduleChatMove ?? (() => { }), getChatOwner: overrides?.getChatOwner ?? mintedTestChatOwner, getDefaultChat: overrides?.getDefaultChat ?? recordedDefaultChat, }; @@ -855,10 +867,40 @@ suite('SessionServerTools', () => { status: 'inputNeeded', workingDirectory: workspace.toString(), title: 'title-s1', + chats: [ + { title: 'title-s1', openLink: 'agent-host-session://copilot/s1', main: true }, + { openLink: 'agent-host-session://copilot/s1?chat=peer' }, + { openLink: 'agent-host-session://copilot/s1?chat=c9' }, + ], }], }); }); + test('serializeSessions lists a session\'s main session and nested sessions in order, with their links, leaving out subagent and hidden chats', () => { + const session = 'copilot:/nested'; + const chat = (id: string) => URI.parse(buildChatUri(session, id)); + const nested: IAgentSessionMetadata = { + session: URI.parse(session), startTime: 0, modifiedTime: 0, summary: 'Nested', + chats: [ + { chat: chat('beta'), kind: 'peer', summary: 'Beta', status: SessionStatus.InProgress }, + { chat: URI.parse(buildDefaultChatUri(session)), kind: 'default' }, + { chat: chat('subagent'), kind: 'peer', summary: 'Subagent', origin: { kind: ChatOriginKind.Tool, chat: buildDefaultChatUri(session), toolCallId: 'tc' } }, + { chat: chat('hidden'), kind: 'peer', summary: 'Hidden', interactivity: ChatInteractivity.Hidden }, + { chat: chat('alpha'), kind: 'peer', summary: 'Alpha' }, + ], + }; + const single: IAgentSessionMetadata = { session: URI.parse('copilot:/single'), startTime: 0, modifiedTime: 0, chats: [{ chat: URI.parse(buildDefaultChatUri('copilot:/single')), kind: 'default' }] }; + const [withChats, withoutChats] = JSON.parse(serializeSessions([nested, single])).sessions; + assert.deepStrictEqual({ chats: withChats.chats, single: withoutChats.chats }, { + chats: [ + { title: 'Beta', openLink: buildOpenSessionLinkUri(session, 'beta'), status: 'inProgress' }, + { title: 'Nested', openLink: buildOpenSessionLinkUri(session), main: true }, + { title: 'Alpha', openLink: buildOpenSessionLinkUri(session, 'alpha') }, + ], + single: undefined, + }); + }); + test('serializeSessions includes meaningful metadata when present', () => { let meta = withSessionGitState(undefined, { branchName: 'feature/x', baseBranchName: 'main', outgoingChanges: 2, incomingChanges: 1, uncommittedChanges: 3 }); meta = withSessionGitHubState(meta, workspace.toString(), { owner: 'microsoft', repo: 'vscode', pullRequestUrls: ['https://github.com/microsoft/vscode/pull/1'] }); @@ -912,6 +954,11 @@ suite('SessionServerTools', () => { workingDirectories: [primary.toString(), secondary.toString()], project: 'Remote App', projectUri: project.toString(), + chats: [ + { title: 'title-remote', openLink: 'agent-host-session://copilot/remote', main: true }, + { openLink: 'agent-host-session://copilot/remote?chat=peer' }, + { openLink: 'agent-host-session://copilot/remote?chat=c9' }, + ], }); }); @@ -3072,6 +3119,19 @@ suite('SessionServerTools', () => { store.dispose(); }); + test('get_current_session lists the current session\'s main session and nested sessions', async () => { + const store = new DisposableStore(); + const stateManager = store.add(new AgentHostStateManager(new NullLogService())); + const group = createSessionServerToolGroup(createAccessor()); + const parsed = JSON.parse(await group.execute(stateManager, executionContext('copilot:/s1'), SessionServerToolName.GetCurrentSession, {})); + assert.deepStrictEqual(parsed.chats, [ + { title: 'title-s1', openLink: 'agent-host-session://copilot/s1', main: true }, + { openLink: buildOpenSessionLinkUri('copilot:/s1', 'peer') }, + { openLink: buildOpenSessionLinkUri('copilot:/s1', 'c9') }, + ]); + store.dispose(); + }); + test('get_current_session does not depend on listing sessions', async () => { const store = new DisposableStore(); const stateManager = store.add(new AgentHostStateManager(new NullLogService())); @@ -3089,6 +3149,11 @@ suite('SessionServerTools', () => { title: 'title-s1', status: 'idle', workingDirectory: 'file:///workspace/app', + chats: [ + { title: 'title-s1', openLink: 'agent-host-session://codex/s1', main: true }, + { openLink: 'agent-host-session://codex/s1?chat=peer' }, + { openLink: 'agent-host-session://codex/s1?chat=c9' }, + ], }); store.dispose(); }); @@ -3114,4 +3179,404 @@ suite('SessionServerTools', () => { assert.strictEqual(deleted?.toString(), 'copilot:/s2'); assert.ok(text.includes('copilot:/s2')); }); + + suite('move_session', () => { + const source = 'copilot:/s1'; + const target = 'copilot:/s2'; + const other = 'copilot:/s3'; + const caller = 'copilot:/caller'; + const sessions = [sessionMeta('s1', SessionStatus.Idle, workspace), sessionMeta('s2', SessionStatus.Idle, workspace), sessionMeta('s3', SessionStatus.Idle, workspace), sessionMeta('caller', SessionStatus.InProgress, workspace)]; + const link = (session: string, chatId?: string) => buildOpenSessionLinkUri(session, chatId); + const scheduledResult = (what: string) => `This chat will be ${what} when this turn ends. Make this your final tool call: reply with one short sentence, then end the turn. Do not call move_session again for this chat.`; + + function describeMove(move: ISessionServerToolMove): string { + const anchor = (after: SessionServerToolMoveAnchor) => after === 'end' ? 'the end' : after.toString(); + if (move.kind === 'session') { + return `session ${move.session} into ${move.destination} after ${anchor(move.after)}`; + } + return move.destination.kind === 'newSession' + ? `chat ${move.chat} of ${move.session} to a new session` + : `chat ${move.chat} of ${move.session} into ${move.destination.session} after ${anchor(move.destination.after)}`; + } + + function createMoveGroup(overrides?: Partial, limits = true) { + const applied: string[] = []; + const checked: string[] = []; + const scheduled: string[] = []; + const accessor = createAccessor({ + listSessions: async () => sessions, + checkChatMove: async move => { checked.push(describeMove(move)); }, + applyChatMove: async move => { + applied.push(describeMove(move)); + return move.kind === 'session' ? move.destination : move.destination.kind === 'session' ? move.destination.session : URI.parse('copilot:/promoted'); + }, + scheduleChatMove: (chat, turnId, move) => { scheduled.push(`${chat} in ${turnId}: ${describeMove(move)}`); }, + ...overrides, + }); + return { group: createSessionMoveToolGroup(accessor, () => limits), applied, checked, scheduled }; + } + + /** Runs the tool in {@link chatUri}; `turnId: null` runs it outside a turn. */ + async function run(group: IServerToolGroup, args: unknown, chatUri = buildDefaultChatUri(caller), turnId: string | null = 'turn-1'): Promise { + const stateManager = new AgentHostStateManager(new NullLogService()); + try { + return await group.execute(stateManager, { sessionUri: caller, chatUri, turnId: turnId ?? undefined }, SessionServerToolName.MoveSession, args); + } catch (error) { + return `error: ${(error as Error).message}`; + } finally { + stateManager.dispose(); + } + } + + test('maps each operation to the host move, resolving links against the sessions\' recorded chats', async () => { + const { group, applied } = createMoveGroup(); + const results = [ + await run(group, { session: link(source, 'peer'), into: link(source), after: link(source, 'c9') }), + await run(group, { session: link(source, 'peer'), into: target }), + await run(group, { session: link(source, 'c9'), relationship: 'independent' }), + await run(group, { session: link(target), into: link(source), after: link(source) }), + ]; + assert.deepStrictEqual({ applied, results }, { + applied: [ + `chat ${buildChatUri(source, 'peer')} of ${source} into ${source} after ${buildChatUri(source, 'c9')}`, + `chat ${buildChatUri(source, 'peer')} of ${source} into ${target} after the end`, + `chat ${buildChatUri(source, 'c9')} of ${source} to a new session`, + `session ${target} into ${source} after ${buildDefaultChatUri(source)}`, + ], + results: [ + `Nested session reordered in its session (${link(source, 'peer')}).`, + `Nested session moved into ${link(target)} (${link(target, 'peer')}).`, + `Nested session moved to its own session (${link('copilot:/promoted')}).`, + `Session moved into ${link(source)} as a nested session (${link(source, getAgentHostChatId(buildDefaultChatUri(target)))}).`, + ], + }); + }); + + test('a move of the calling chat takes its session from recorded ownership, never its URI, and waits for the end of the turn', async () => { + // Minted for s1, the chat moved to s2 since: only its recorded owner and s2's listing say so. + const moved = buildChatUri(source, 'moved'); + const listed = sessions.map(meta => meta.session.toString() === target ? { ...meta, chats: [...meta.chats ?? [], { chat: URI.parse(moved), kind: 'peer' as const }] } : meta); + const { group, applied, checked, scheduled } = createMoveGroup({ + listSessions: async () => listed, + getChatOwner: chat => chat === moved ? target : mintedTestChatOwner(chat), + }); + const results = [ + await run(group, { relationship: 'independent' }, moved), + await run(group, { session: link(target, 'moved'), into: link(other) }, moved), + await run(group, { into: link(source) }, buildDefaultChatUri(other)), + await run(group, { relationship: 'independent' }, moved, null), + ]; + const moves = [ + `chat ${moved} of ${target} to a new session`, + `chat ${moved} of ${target} into ${other} after the end`, + `session ${other} into ${source} after the end`, + ]; + assert.deepStrictEqual({ applied, checked, scheduled, results }, { + applied: [], + checked: moves, + scheduled: [`${moved} in turn-1: ${moves[0]}`, `${moved} in turn-1: ${moves[1]}`, `${buildDefaultChatUri(other)} in turn-1: ${moves[2]}`], + results: [ + scheduledResult('moved to its own session'), + scheduledResult(`moved into ${link(other)}`), + scheduledResult(`moved into ${link(source)}`), + 'error: move_session must run from an active chat turn to move the chat it runs in.', + ], + }); + }); + + test('refuses invalid arguments before moving anything', async () => { + const { group, applied } = createMoveGroup(); + const results = [ + await run(group, { session: link(source, 'peer'), relationship: 'independent', into: target }), + await run(group, { session: link(source, 'peer') }), + await run(group, { session: link(source, 'peer'), relationship: 'sideways', into: target }), + await run(group, { session: 'copilot:/unknown', into: target }), + await run(group, { session: link(source, 'unknown'), into: target }), + await run(group, { session: link(target), relationship: 'independent' }), + await run(group, { session: link(source, 'peer'), into: target, after: link(source, 'c9') }), + await run(group, { session: link(source, 'peer'), into: source, after: link(source, 'peer') }), + await run(group, { session: buildChatUri(source, 'peer'), into: target }), + ]; + assert.deepStrictEqual({ applied, results }, { + applied: [], + results: [ + 'error: Invalid move_session input: omit `into` and `after` with relationship "independent".', + 'error: Invalid move_session input: `into` is required unless relationship is "independent".', + 'error: Invalid move_session input: relationship must be one of nested, independent.', + 'error: Invalid move_session input: session must be an `agent-host-session://` link or the URI of a known session (see list_sessions).', + 'error: Invalid move_session input: session\'s chat id must name a nested session of that session (see list_sessions).', + 'error: Invalid move_session input: it is already its own session. To make a nested session its own session, pass its link with its chat id.', + 'error: Invalid move_session input: after must be a link to a nested session of `into`, or to its main session. Omit it to place the session at the end.', + 'error: Invalid move_session input: after must name another session than the one that moves.', + // A chat URI names no session: it is never read for its owner. + 'error: Invalid move_session input: session must be an `agent-host-session://` link or the URI of a known session (see list_sessions).', + ], + }); + }); + + test('turns every host refusal into one actionable sentence', async () => { + // A record over the whole enum: a new refusal fails to compile here until it has a message. + const reasons: Record = { + [AgentChatMoveRefusal.Disabled]: true, + [AgentChatMoveRefusal.NotMovable]: true, + [AgentChatMoveRefusal.DestinationNotFound]: true, + [AgentChatMoveRefusal.InvalidAnchor]: true, + [AgentChatMoveRefusal.DifferentProvider]: true, + [AgentChatMoveRefusal.SessionArchived]: true, + [AgentChatMoveRefusal.SessionNotReady]: true, + [AgentChatMoveRefusal.SessionQuarantined]: true, + [AgentChatMoveRefusal.ConversionInProgress]: true, + [AgentChatMoveRefusal.WorktreeFolder]: true, + [AgentChatMoveRefusal.Workspaceless]: true, + [AgentChatMoveRefusal.AgentMergeOwner]: true, + [AgentChatMoveRefusal.DestinationSingleFolder]: true, + [AgentChatMoveRefusal.LegacyCatalog]: true, + [AgentChatMoveRefusal.Conflict]: true, + [AgentChatMoveRefusal.Unsupported]: true, + [AgentChatMoveRefusal.NotSingleChat]: true, + [AgentChatMoveRefusal.NotStarted]: true, + [AgentChatMoveRefusal.DifferentCredentialOwner]: true, + }; + const refusing = (reason: AgentChatMoveRefusal) => createMoveGroup({ applyChatMove: async () => { throw new AgentChatMoveRefusedError(reason, 'host detail'); } }).group; + const messages: string[] = []; + for (const reason of Object.keys(reasons) as AgentChatMoveRefusal[]) { + messages.push(await run(refusing(reason), { session: link(source, 'peer'), into: target, after: link(target) })); + } + assert.deepStrictEqual({ + count: messages.length, + distinct: new Set(messages).size, + oneSentenceEach: messages.every(message => message.startsWith('error: Cannot move: ') && message.endsWith('.') && !/\.\s/.test(message.slice(0, -1))), + hostDetailHidden: messages.every(message => !message.includes('host detail')), + // An anchor the tool chose itself that is gone means the destination changed meanwhile. + endAnchorGone: await run(refusing(AgentChatMoveRefusal.InvalidAnchor), { session: link(source, 'peer'), into: target }), + givenAnchorGone: await run(refusing(AgentChatMoveRefusal.InvalidAnchor), { session: link(source, 'peer'), into: target, after: link(target) }), + }, { + count: 19, + distinct: 19, + oneSentenceEach: true, + hostDetailHidden: true, + endAnchorGone: `error: ${getMoveRefusalMessage(AgentChatMoveRefusal.Conflict, false)}`, + givenAnchorGone: `error: ${getMoveRefusalMessage(AgentChatMoveRefusal.InvalidAnchor, false)}`, + }); + }); + + test('a move of the calling chat the host would refuse is refused when requested, not scheduled', async () => { + const archived = createMoveGroup({ checkChatMove: async () => { throw new AgentChatMoveRefusedError(AgentChatMoveRefusal.SessionArchived, 'archived'); } }); + const unsupported = createMoveGroup({ checkChatMove: async () => { throw new AgentChatMoveRefusedError(AgentChatMoveRefusal.Unsupported, 'no backing'); } }); + assert.deepStrictEqual({ + archived: await run(archived.group, { into: link(target) }, buildChatUri(caller, 'peer')), + unsupported: await run(unsupported.group, { relationship: 'independent' }, buildChatUri(caller, 'peer')), + scheduled: [...archived.scheduled, ...unsupported.scheduled], + }, { + archived: `error: ${getMoveRefusalMessage(AgentChatMoveRefusal.SessionArchived, false)}`, + unsupported: `error: ${getMoveRefusalMessage(AgentChatMoveRefusal.Unsupported, false)}`, + scheduled: [], + }); + }); + + test('is offered only while moving chats is enabled, for an agent with nested sessions; otherwise every tool list is unchanged', async () => { + const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + const session = 'copilot:/gated'; + stateManager.createSession({ + resource: session, + provider: 'copilot', + title: 'Session', + status: SessionStatus.Idle, + createdAt: new Date(0).toISOString(), + modifiedAt: new Date(0).toISOString(), + defaultChat: buildDefaultChatUri(session), + }); + let enabled = false; + let supported = true; + const accessor = createAccessor({ isChatMoveEnabled: () => enabled, supportsChatMoves: () => supported }); + const groups = buildServerToolGroups(accessor, undefined, undefined, () => true); + const host = new AgentServerToolHost(stateManager, groups); + const withoutMoves = new AgentServerToolHost(stateManager, groups.filter(group => !group.definitions.some(definition => definition.name === SessionServerToolName.MoveSession))); + const lists = (candidate: AgentServerToolHost) => JSON.stringify({ definitions: candidate.definitions, toolNames: candidate.toolNames, session: candidate.getDefinitionsForSession(session) }); + const offered = () => host.getDefinitionsForSession(session).some(definition => definition.name === SessionServerToolName.MoveSession); + const disabledRun = await run(createMoveGroup({ isChatMoveEnabled: () => false }).group, { session: link(source, 'peer'), into: target }); + + const off = { unchangedLists: lists(host) === lists(withoutMoves), offered: offered(), canRequireConfirmation: host.canRequireConfirmation(SessionServerToolName.MoveSession) }; + enabled = true; + const on = { + offered: offered(), + deferred: host.getDefinitionsForSession(session).find(definition => definition.name === SessionServerToolName.MoveSession)?.deferLoading, + materialized: groups.find(group => group.definitions.some(definition => definition.name === SessionServerToolName.MoveSession))?.materializeDefinitions ?? false, + canRequireConfirmation: host.canRequireConfirmation(SessionServerToolName.MoveSession), + requiresConfirmation: host.requiresConfirmation(buildDefaultChatUri(session), SessionServerToolName.MoveSession), + }; + supported = false; + const unsupported = { offered: offered() }; + + assert.deepStrictEqual({ off, on, unsupported, disabledRun, confirms: sessionToolRequiresConfirmation(SessionServerToolName.MoveSession) }, { + off: { unchangedLists: true, offered: false, canRequireConfirmation: false }, + on: { offered: true, deferred: true, materialized: false, canRequireConfirmation: true, requiresConfirmation: true }, + unsupported: { offered: false }, + disabledRun: `error: ${getMoveRefusalMessage(AgentChatMoveRefusal.Disabled, false)}`, + confirms: true, + }); + }); + + test('list_sessions and get_current_session mention move_session only while it is offered; their chats are listed either way', async () => { + const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + stateManager.createSession({ + resource: source, + provider: 'copilot', + title: 'Session', + status: SessionStatus.Idle, + createdAt: new Date(0).toISOString(), + modifiedAt: new Date(0).toISOString(), + defaultChat: buildDefaultChatUri(source), + }); + let enabled = false; + let supported = true; + const accessor = createAccessor({ isChatMoveEnabled: () => enabled, supportsChatMoves: () => supported, listSessions: async () => sessions }); + const host = new AgentServerToolHost(stateManager, buildServerToolGroups(accessor, undefined, undefined, () => true)); + const descriptions = () => Object.fromEntries(host.getDefinitionsForSession(source) + .filter(definition => definition.name === SessionServerToolName.ListSessions || definition.name === SessionServerToolName.GetCurrentSession) + .map(definition => [definition.name, definition.description])); + const listed = async () => JSON.parse(await host.executeTool(buildDefaultChatUri(source), SessionServerToolName.ListSessions, { session: source })).sessions[0].chats?.length; + const base = Object.fromEntries(sessionServerToolDefinitions + .filter(definition => definition.name === SessionServerToolName.ListSessions || definition.name === SessionServerToolName.GetCurrentSession) + .map(definition => [definition.name, definition.description])); + const off = { descriptions: descriptions(), chats: await listed() }; + enabled = true; + const on = { descriptions: descriptions(), chats: await listed() }; + // Enabled, but the session's agent can't move chats: no move_session, so no mention of it. + supported = false; + const unsupported = { descriptions: descriptions(), chats: await listed() }; + assert.deepStrictEqual(unsupported, off); + assert.deepStrictEqual({ off, on, baseMentionsMove: Object.values(base).some(description => description?.includes('move_session')) }, { + off: { descriptions: base, chats: 3 }, + on: { + // Right after the sentence about `chats`, before the rest of the description. + descriptions: Object.fromEntries(Object.entries(base).map(([name, description]) => [name, description?.replace(name === SessionServerToolName.ListSessions ? 'and `rename_chat`.' : 'like `list_sessions`.', match => `${match} \`move_session\` takes these links too.`)])), + chats: 3, + }, + baseMentionsMove: false, + }); + assert.deepStrictEqual(Object.values(on.descriptions).map(description => description?.endsWith('takes these links too.')), [false, false]); + }); + + test('every call asks for confirmation, with a display for its operation that links what moves when the host knows it', () => { + // The host's display links only what `isKnownSessionLink` confirms: here s1 and its nested session peer, and s2. + const known = (session: URI, chatId: string | undefined) => (session.toString() === source && (chatId === undefined || chatId === 'peer')) || (session.toString() === target && chatId === undefined); + const group = createMoveGroup({ isKnownSessionLink: known }).group; + const show = (value: ReturnType>) => { + const message = value?.confirmationMessage; + return [value?.confirmationTitle, typeof message === 'string' ? message : message?.markdown, value?.invocationMessage, value?.pastTenseMessage, value?.hideConfirmationInput ?? false]; + }; + const display = (args: unknown, result?: { text: string; success: boolean }) => show(group.getDisplay!(SessionServerToolName.MoveSession, args, result)); + const md = (label: string, target: string) => `[${label}](<${target}>)`; + assert.deepStrictEqual({ + reorder: display({ session: link(source, 'peer'), into: link(source) }), + move: display({ session: link(source, 'peer'), into: target }), + promote: display({ session: link(source, 'peer'), relationship: 'independent' }), + session: display({ session: link(target), into: source }), + self: display({ into: target }, { text: scheduledResult(`moved into ${link(target)}`), success: true }), + selfPromote: display({ relationship: 'independent' }), + // Only a known session or chat is linked; an unknown one, a chat URI or a non-link is named without a link. + unknown: display({ session: link(source, 'gone'), into: link('copilot:/unknown') }), + notALink: display({ session: buildChatUri(source, 'peer'), into: 'not a session' }), + noArgs: display(undefined), + // Restored history has no host to check links against: it names sessions without links. + history: show(getServerToolDisplay(SessionServerToolName.MoveSession, { session: link(source, 'peer'), into: target })), + }, { + reorder: ['Reorder Nested Session?', `Move ${md('this nested session', link(source, 'peer'))} to a new position in its session?`, 'Reordering nested session', 'Reordered nested session', true], + move: ['Move Nested Session?', `Move ${md('this nested session', link(source, 'peer'))} into ${md('the destination session', link(target))}? It keeps its history and folders.`, 'Moving nested session', 'Moved nested session', true], + promote: ['Move to Its Own Session?', `Make ${md('this nested session', link(source, 'peer'))} its own session? It keeps its history and folders.`, 'Moving nested session to its own session', 'Moved nested session to its own session', true], + session: ['Move Session?', `Move ${md('this session', link(target))} into ${md('the destination session', link(source))} as a nested session? It keeps its history and folders.`, 'Moving session', 'Moved session', true], + self: ['Move This Chat?', `Move this chat into ${md('the destination session', link(target))} when this turn ends? It keeps its history and folders.`, 'Scheduling a move of this chat', 'Scheduled move', true], + selfPromote: ['Move This Chat to Its Own Session?', 'Make this chat its own session when this turn ends? It keeps its history and folders.', 'Scheduling a move of this chat', 'Processed move of this chat', true], + unknown: ['Move Nested Session?', 'Move this nested session into the destination session? It keeps its history and folders.', 'Moving nested session', 'Moved nested session', true], + notALink: ['Move Session?', 'Move this session into the destination session as a nested session? It keeps its history and folders.', 'Moving session', 'Moved session', true], + noArgs: ['Move Session?', 'Move this session?', 'Moving session', 'Processed session move', false], + history: ['Move Nested Session?', 'Move this nested session into the destination session? It keeps its history and folders.', 'Moving nested session', 'Moved nested session', true], + }); + }); + + test('the host builds the confirmation providers show with its state', () => { + const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + const host = new AgentServerToolHost(stateManager, buildServerToolGroups(createAccessor({ isKnownSessionLink: () => false }), undefined, undefined, () => true)); + const known = new AgentServerToolHost(stateManager, buildServerToolGroups(createAccessor(), undefined, undefined, () => true)); + const args = { session: link(source, 'peer'), into: target }; + assert.deepStrictEqual({ + unknown: host.getConfirmationDisplay(SessionServerToolName.MoveSession, args), + known: known.getConfirmationDisplay(SessionServerToolName.MoveSession, args)?.confirmationMessage, + }, { + unknown: { confirmationTitle: 'Move Nested Session?', confirmationMessage: { markdown: 'Move this nested session into the destination session? It keeps its history and folders.' }, hideConfirmationInput: true }, + known: { markdown: `Move [this nested session](<${link(source, 'peer')}>) into [the destination session](<${link(target)}>)? It keeps its history and folders.` }, + }); + }); + + test('a whole session the host can\'t move is explained as such, not as a nested session that stays', async () => { + const refusing = createMoveGroup({ applyChatMove: async () => { throw new AgentChatMoveRefusedError(AgentChatMoveRefusal.NotMovable, 'not available'); } }).group; + assert.deepStrictEqual({ + session: await run(refusing, { session: link(target), into: source }), + nested: await run(refusing, { session: link(source, 'peer'), into: target }), + }, { + session: `error: ${getMoveRefusalMessage(AgentChatMoveRefusal.NotMovable, true)}`, + nested: `error: ${getMoveRefusalMessage(AgentChatMoveRefusal.NotMovable, false)}`, + }); + assert.notStrictEqual(getMoveRefusalMessage(AgentChatMoveRefusal.NotMovable, true), getMoveRefusalMessage(AgentChatMoveRefusal.NotMovable, false)); + }); + + test('refuses more than 50 moves in a process unless orchestration limits are off', async () => { + const limited = createMoveGroup(); + const unlimited = createMoveGroup(undefined, false); + const args = { session: link(source, 'peer'), into: target }; + for (let i = 0; i < 50; i++) { + await run(limited.group, args); + await run(unlimited.group, args); + } + assert.deepStrictEqual({ + limited: await run(limited.group, args), + unlimited: await run(unlimited.group, args), + applied: [limited.applied.length, unlimited.applied.length], + }, { + limited: 'error: Refusing to move more than 50 sessions from server tools in this process.', + unlimited: `Nested session moved into ${link(target)} (${link(target, 'peer')}).`, + applied: [50, 51], + }); + }); + + test('concurrent calls can\'t pass the 50-move limit together; a move that doesn\'t happen frees its place', async () => { + const release = new DeferredPromise(); + let slow = false; + const { group, applied } = createMoveGroup({ + applyChatMove: async move => { + if (slow) { + await release.p; + } + applied.push('applied'); + return move.kind === 'session' ? move.destination : URI.parse(target); + }, + }); + const args = { session: link(source, 'peer'), into: target }; + for (let i = 0; i < 48; i++) { + await run(group, args); + } + // A refused move doesn't count. + const refused = await run(group, { session: link(source, 'unknown'), into: target }); + slow = true; + const concurrent = [run(group, args), run(group, args), run(group, args)]; + release.complete(); + const results = await Promise.all(concurrent); + assert.deepStrictEqual({ + refused: refused.startsWith('error: Invalid move_session input'), + applied: applied.length, + refusedByLimit: results.filter(result => result === 'error: Refusing to move more than 50 sessions from server tools in this process.').length, + }, { refused: true, applied: 50, refusedByLimit: 1 }); + }); + + test('the description states the cross-field rules, limits it to moves the user asks for and makes a move of the calling chat the final tool call', () => { + const description = moveSessionToolDefinition.description ?? ''; + assert.deepStrictEqual([ + 'Pass `into` for every move except `independent`, which takes neither `into` nor `after`.', + 'Use this only when the user asks to move or reorganize sessions; never to tidy up sessions on your own.', + 'The user is asked to confirm every move.', + 'make it your final tool call', + ].filter(clause => !description.includes(clause)), []); + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/shared/copilotApiService.integrationTest.ts b/src/vs/platform/agentHost/test/node/shared/copilotApiService.integrationTest.ts index 29d8096b4c8709..e3f6d4c97f46d5 100644 --- a/src/vs/platform/agentHost/test/node/shared/copilotApiService.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/shared/copilotApiService.integrationTest.ts @@ -21,6 +21,7 @@ import product from '../../../../product/common/product.js'; import { IProductService } from '../../../../product/common/productService.js'; import { CopilotApiService } from '../../../node/shared/copilotApiService.js'; import { createTestGitHubEndpointService } from '../testGitHubEndpointService.js'; +import { NullTelemetryService } from '../../../../telemetry/common/telemetryUtils.js'; suite('CopilotApiService.utilityChatCompletion (real CAPI)', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -35,7 +36,7 @@ suite('CopilotApiService.utilityChatCompletion (real CAPI)', () => { // `globalThis.fetch` through `this._fetch(...)` throws // "Illegal invocation" in the Electron renderer. const boundFetch: typeof globalThis.fetch = (...args) => globalThis.fetch(...args); - return disposables.add(new CopilotApiService(boundFetch, new NullLogService(), productService, createTestGitHubEndpointService())); + return disposables.add(new CopilotApiService(boundFetch, new NullLogService(), productService, createTestGitHubEndpointService(), NullTelemetryService)); } (hasToken ? test : test.skip)('answers a trivial arithmetic prompt', async function () { diff --git a/src/vs/platform/agentHost/test/node/shared/copilotApiService.test.ts b/src/vs/platform/agentHost/test/node/shared/copilotApiService.test.ts index 0cc919d2095cb8..6886695c9ea377 100644 --- a/src/vs/platform/agentHost/test/node/shared/copilotApiService.test.ts +++ b/src/vs/platform/agentHost/test/node/shared/copilotApiService.test.ts @@ -15,11 +15,21 @@ import { NullLogService } from '../../../../log/common/log.js'; import { IProductService } from '../../../../product/common/productService.js'; import product from '../../../../product/common/product.js'; import { captureCopilotTelemetryContext, toCopilotTelemetryData } from '../../../node/shared/copilotSkuTelemetry.js'; +import { ITelemetryData, ITelemetryService } from '../../../../telemetry/common/telemetry.js'; +import { NullTelemetryService, NullTelemetryServiceShape } from '../../../../telemetry/common/telemetryUtils.js'; // #region Test Helpers const testProductService: IProductService = { _serviceBrand: undefined, ...product }; +class DiscoveryTelemetryService extends NullTelemetryServiceShape { + readonly events: { name: string | undefined; data?: ITelemetryData }[] = []; + + override publicLog2(name?: string, data?: ITelemetryData): void { + this.events.push({ name, data }); + } +} + function sseLines(...lines: string[]): Uint8Array { return new TextEncoder().encode(lines.join('\n') + '\n'); } @@ -125,13 +135,220 @@ suite('CopilotApiService', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - function createService(fetchImpl: FetchFunction, enterpriseUri?: string): CopilotApiService { - return disposables.add(new CopilotApiService(fetchImpl, new NullLogService(), testProductService, createTestGitHubEndpointService(enterpriseUri))); + function createService(fetchImpl: FetchFunction, enterpriseUri?: string, telemetryService: ITelemetryService = NullTelemetryService): CopilotApiService { + return disposables.add(new CopilotApiService(fetchImpl, new NullLogService(), testProductService, createTestGitHubEndpointService(enterpriseUri), telemetryService)); } + suite('discovery telemetry', () => { + teardown(() => sinon.restore()); + + test('reports actual discovery, trigger, returned metadata and previous cache age without cache-hit events', async () => { + const clock = sinon.useFakeTimers({ now: 1_000_000, toFake: ['Date'] }); + const telemetry = new DiscoveryTelemetryService(); + let requests = 0; + const service = createService(async () => { + requests++; + clock.tick(25); + const response = userResponse({ + access_type_sku: requests === 1 ? 'no_access' : 'free_limited_copilot', + analytics_tracking_id: 'analytics', + }); + response.headers.set('x-github-request-id', 'D3ED:ABCD:1234'); + return response; + }, undefined, telemetry); + await service.resolveCopilotSku('private-token'); + clock.tick(1000); + await service.resolveCopilotSku('private-token'); + await service.refreshCopilotUser('private-token', 'analytics', 'free_limited_copilot'); + clock.tick(26 * 60 * 1000); + await service.resolveCopilotSku('private-token'); + + const common = { + outcome: 'published', durationMs: 25, httpStatus: 200, + gitHubRequestId: 'D3ED:ABCD:1234', copilotTrackingId: 'analytics', + }; + assert.deepStrictEqual({ requests, events: telemetry.events }, { + requests: 3, + events: [ + { name: 'agentHost.copilotAccountDiscovery', data: { ...common, trigger: 'initial', copilotSku: 'no_access' } }, + { + name: 'agentHost.copilotAccountDiscovery', data: { + ...common, trigger: 'entitlementChange', copilotSku: 'free_limited_copilot', previousSku: 'no_access', cacheAgeMs: 1000, + } + }, + { + name: 'agentHost.copilotAccountDiscovery', data: { + ...common, trigger: 'cacheRefresh', copilotSku: 'free_limited_copilot', previousSku: 'free_limited_copilot', cacheAgeMs: 26 * 60 * 1000, + } + }, + ], + }); + }); + + for (const failure of ['http', 'network', 'body']) { + test(`reports bounded ${failure} failure metadata without bodies, errors, credentials or invalid request IDs`, async () => { + sinon.useFakeTimers({ now: 1_000_000, toFake: ['Date'] }); + const telemetry = new DiscoveryTelemetryService(); + const service = createService(async () => { + if (failure === 'network') { + throw new Error('private-error-content'); + } + return new Response('private-response-content', { + status: failure === 'http' ? 503 : 200, + headers: { 'x-github-request-id': failure === 'http' ? 'D3ED:FAILED:1' : 'https://private.example/user' }, + }); + }, undefined, telemetry); + await assert.rejects(service.resolveCopilotSku('private-token')); + assert.deepStrictEqual(telemetry.events, [{ + name: 'agentHost.copilotAccountDiscovery', + data: { + trigger: 'initial', outcome: 'failed', durationMs: 0, + ...(failure !== 'network' ? { httpStatus: failure === 'http' ? 503 : 200 } : {}), + ...(failure === 'http' ? { gitHubRequestId: 'D3ED:FAILED:1' } : {}), + }, + }]); + }); + } + + test('does not send discovery events after service disposal', async () => { + sinon.useFakeTimers({ now: 1_000_000, toFake: ['Date'] }); + const telemetry = new DiscoveryTelemetryService(); + const response = new DeferredPromise(); + const started = new DeferredPromise(); + let requests = 0; + const service = createService(async () => { + if (++requests === 1) { + return userResponse({ access_type_sku: 'no_access', analytics_tracking_id: 'analytics' }); + } + started.complete(); + return response.p; + }, undefined, telemetry); + await service.resolveCopilotSku('private-token'); + const refreshing = service.refreshCopilotUser('private-token', 'analytics', 'free'); + await started.p; + service.dispose(); + response.complete(userResponse({ access_type_sku: 'free', analytics_tracking_id: 'analytics' })); + await refreshing; + assert.deepStrictEqual(telemetry.events, [{ + name: 'agentHost.copilotAccountDiscovery', + data: { trigger: 'initial', outcome: 'published', durationMs: 0, copilotSku: 'no_access', copilotTrackingId: 'analytics', httpStatus: 200 }, + }]); + }); + }); + suite('account telemetry context', () => { teardown(() => sinon.restore()); + test('revalidates changed entitlements without copying client values or affecting another token', async () => { + let requests = 0; + const service = createService(async () => { + requests++; + return userResponse({ access_type_sku: requests <= 2 ? 'no_access' : 'server-sku', analytics_tracking_id: 'analytics' }); + }); + await service.resolveCopilotSku('token-a'); + await service.resolveCopilotSku('token-b'); + const read = service.captureCopilotTelemetryContext('token-a'); + await service.refreshCopilotUser('token-a', 'another-account', 'free'); + await service.refreshCopilotUser('token-a', 'analytics', 'no_access'); + const before = { requests, context: read() }; + await Promise.all([ + service.refreshCopilotUser('token-a', 'analytics', 'free'), + service.refreshCopilotUser('token-a', 'analytics', 'free'), + ]); + assert.deepStrictEqual({ before, requests, context: read(), other: service.getCachedCopilotSku('token-b') }, { + before: { requests: 2, context: { copilotSku: 'no_access', copilotTrackingId: 'analytics' } }, + requests: 3, + context: { copilotSku: 'server-sku', copilotTrackingId: 'analytics' }, + other: 'no_access', + }); + }); + + test('keeps existing discovery usable during refresh and clears stale telemetry after failure', async () => { + const refresh = new DeferredPromise(); + let requests = 0; + const service = createService(async () => ++requests === 1 + ? userResponse({ access_type_sku: 'no_access', analytics_tracking_id: 'analytics' }) + : refresh.p); + await service.resolveCopilotSku('token'); + const read = service.captureCopilotTelemetryContext('token'); + const work = service.refreshCopilotUser('token', 'analytics', 'free'); + const pending = await service.resolveCopilotSku('token'); + refresh.complete(new Response('Unavailable', { status: 503 })); + await assert.rejects(work); + assert.deepStrictEqual({ pending, after: read() }, { pending: 'no_access', after: undefined }); + }); + + for (const entitlementStartsFirst of [false, true]) { + for (const entitlementFinishesFirst of [false, true]) { + test(`entitlement cache writes supersede ordinary refresh: starts first ${entitlementStartsFirst}, finishes first ${entitlementFinishesFirst}`, async () => { + const clock = sinon.useFakeTimers({ now: Date.now(), toFake: ['Date'] }); + const telemetry = new DiscoveryTelemetryService(); + const normalResponse = new DeferredPromise(); + const entitlementResponse = new DeferredPromise(); + let requests = 0; + const service = createService(async () => { + if (++requests === 1) { + return userResponse({ access_type_sku: 'no_access', analytics_tracking_id: 'analytics' }); + } + return (requests === 2) === entitlementStartsFirst ? entitlementResponse.p : normalResponse.p; + }, undefined, telemetry); + await service.resolveCopilotSku('token'); + clock.tick(26 * 60 * 1000); + let normal: Promise; + let entitlement: Promise; + if (entitlementStartsFirst) { + entitlement = service.refreshCopilotUser('token', 'analytics', 'free'); + normal = service.resolveCopilotSku('token'); + } else { + normal = service.resolveCopilotSku('token'); + entitlement = service.refreshCopilotUser('token', 'analytics', 'free'); + } + if (entitlementFinishesFirst) { + entitlementResponse.complete(userResponse({ access_type_sku: 'free', analytics_tracking_id: 'analytics' })); + await entitlement; + normalResponse.complete(userResponse({ access_type_sku: 'no_access', analytics_tracking_id: 'analytics' })); + await normal; + } else { + normalResponse.complete(userResponse({ access_type_sku: 'no_access', analytics_tracking_id: 'analytics' })); + await normal; + entitlementResponse.complete(userResponse({ access_type_sku: 'free', analytics_tracking_id: 'analytics' })); + await entitlement; + } + assert.deepStrictEqual({ + requests, + sku: service.getCachedCopilotSku('token'), + outcomes: telemetry.events.slice(1).map(event => ({ trigger: event.data?.trigger, outcome: event.data?.outcome, sku: event.data?.copilotSku })), + }, { + requests: 3, + sku: 'free', + outcomes: entitlementFinishesFirst ? [ + { trigger: 'entitlementChange', outcome: 'published', sku: 'free' }, + { trigger: 'cacheRefresh', outcome: 'superseded', sku: 'no_access' }, + ] : [ + { trigger: 'cacheRefresh', outcome: 'superseded', sku: 'no_access' }, + { trigger: 'entitlementChange', outcome: 'published', sku: 'free' }, + ], + }); + }); + } + } + + test('rejected entitlement refresh permanently invalidates the captured account context', async () => { + let status = 200; + const service = createService(async () => status === 200 + ? userResponse({ access_type_sku: 'no_access', analytics_tracking_id: 'analytics' }) + : new Response('Rejected', { status })); + await service.resolveCopilotSku('token'); + const old = service.captureCopilotTelemetryContext('token'); + status = 403; + await assert.rejects(service.refreshCopilotUser('token', 'analytics', 'free')); + status = 200; + await service.resolveCopilotSku('token'); + assert.deepStrictEqual({ old: old(), fresh: service.captureCopilotTelemetryContext('token')() }, { + old: undefined, fresh: { copilotSku: 'no_access', copilotTrackingId: 'analytics' }, + }); + }); + test('maps account properties only at emission without leaking internal names or refreshing credentials', async () => { let requests = 0; let current = true; diff --git a/src/vs/platform/agentHost/test/node/shared/sessionMcpDiscovery.test.ts b/src/vs/platform/agentHost/test/node/shared/sessionMcpDiscovery.test.ts index 3fd250148ec250..e41ebc6caa668d 100644 --- a/src/vs/platform/agentHost/test/node/shared/sessionMcpDiscovery.test.ts +++ b/src/vs/platform/agentHost/test/node/shared/sessionMcpDiscovery.test.ts @@ -111,6 +111,21 @@ suite('SessionMcpDiscovery', () => { assert.strictEqual(definition.defaultCwd, primary); }); + test('reads servers written with the legacy Copilot `local` type', async () => { + await write(primary, { + mcpServers: { + server: { type: 'local', command: 'npx', args: ['${MCP_DIR}'], tools: ['*'] }, + } + }); + + const discovery = store.add(new SessionMcpDiscovery([primary], fileService)); + const definitions = await discovery.refresh(); + + assert.deepStrictEqual(definitions.map(definition => ({ name: definition.name, configuration: definition.configuration })), [ + { name: 'server', configuration: { type: McpServerType.LOCAL, command: 'npx', args: ['${MCP_DIR}'], env: undefined, envFile: undefined, cwd: undefined, dev: undefined } }, + ]); + }); + test('ignores malformed files and refreshes after an exact file change', async () => { await fileService.writeFile(URI.joinPath(primary, '.mcp.json'), VSBuffer.fromString('{ malformed')); const discovery = store.add(new SessionMcpDiscovery([primary], fileService)); diff --git a/src/vs/platform/customizationMarketplace/common/customizationMarketplaceService.ts b/src/vs/platform/customizationMarketplace/common/customizationMarketplaceService.ts index d3759432e291a1..34aaa9e2162f40 100644 --- a/src/vs/platform/customizationMarketplace/common/customizationMarketplaceService.ts +++ b/src/vs/platform/customizationMarketplace/common/customizationMarketplaceService.ts @@ -78,6 +78,7 @@ export interface ICustomizationMarketplaceEntry { readonly tags: readonly string[]; readonly capabilities: readonly string[]; readonly representativeQueries: readonly string[]; + readonly connectorIds?: readonly string[]; readonly url?: URI; /** Validated original URL for external opening, preserving escaped path separators. */ readonly externalUrl?: string; diff --git a/src/vs/platform/github/README.md b/src/vs/platform/github/README.md index a2c4b920d4838e..955f3ef2b69cbe 100644 --- a/src/vs/platform/github/README.md +++ b/src/vs/platform/github/README.md @@ -15,7 +15,7 @@ Reusable GitHub engine and cross-target architecture. - The [workbench binding](../../workbench/services/github/browser/githubService.ts) selects the default account or an explicit session in the renderer. Legacy consumers retain their in-process clients. Portable default-account clients execute locally on web and through the [desktop IPC binding](../../workbench/services/github/electron-browser/githubService.ts) on desktop. - The [Agent Host binding](../agentHost/node/agentHostGitHubService.ts) selects its host-owned repository credential resource without an attached workbench. Repository/PR association, creation, merge settings, auto-merge and issue/PR title context use its explicit clients. Copilot discovery and model requests still use the existing [Agent Host Copilot service](../agentHost/node/shared/copilotApiService.ts); migrating them is a separate change. -- The [shared-process binding](electron-utility/githubService.ts) hosts an additional engine with direct Node networking. Its [typed service boundary](common/remote/ipc.ts) supports directly disposable anonymous and explicitly authorized account clients with REST, buffered GraphQL, pinned public raw-content, and Mission Control execution. Account credentials come from the acquiring renderer's callback channel. Sessions GitHub, Cloud Automations, and the issue reporter's anonymous search use this binding on desktop; other feature consumers retain their existing bindings. +- The [shared-process binding](electron-utility/githubService.ts) hosts an additional engine with direct Node networking. Its [typed service boundary](common/remote/ipc.ts) supports directly disposable anonymous and explicitly authorized account clients with REST, buffered GraphQL, pinned public raw-content, and Mission Control execution. Account credentials come from the acquiring renderer's callback channel. Sessions GitHub, Cloud Automations, Issue Reporter anonymous search, and slow-extension issue search use this binding on desktop; other feature consumers retain their existing bindings. - The [Sessions service](../../sessions/contrib/github/browser/githubService.ts) retains its observable models and polling while its fetchers use the portable account API. Extension clients still own independent requests and polling. These instances do not currently share application-wide request state. @@ -84,6 +84,8 @@ Equivalent anonymous clients share requests and ETag state, but never share cach The Issue Reporter's GitHub similar-issue searches, in both the wizard and legacy/web UI, acquire this capability through `IWorkbenchGitHubService.acquireAnonymousApiClient(signal)`: IPC on desktop and local execution on web, without account selection. Each call acquires an anonymous client, searches issues with a ten-second request deadline, and disposes the client when the call finishes. The reporter does not retain a client or manage shared acquisitions; the service owns acquisition cancellation and request coordination. Both search backends invalidate obsolete work when the source/input changes or the reporter is disposed, so late responses cannot cancel or replace a newer search. Existing duplicate-detection service calls and authenticated issue submission remain separate. Anonymous Copilot/device token issuance and general text/binary transfers remain outside this capability. +The [slow-extension performance action](../../workbench/contrib/extensions/electron-browser/extensionsSlowActions.ts) also acquires the anonymous client through the workbench binding, using shared-process IPC on desktop. It searches one issue page with a single result to choose between reporting a new issue and showing existing issues. Client acquisition and search share a ten-second budget, and the client and timeout are released after every lookup. Failures are logged and do not offer an action, prompt for sign-in, or fall back to a direct request; profile export and issue-opening behavior are unchanged. + Existing consumers use these clients directly; there is no compatibility singleton API for queries or mutations. Agent Merge captures its authorized client with the turn. Host token refresh preserves the client and rotates credentials on the next request; revocation, endpoint changes and a resolved account change reset the dependent runtime. Async consumers release references that arrive after their owning scope has ended and do not install subscriptions with invalidated credentials. VS Code forwards optional account provenance through the standard authentication `_meta` bag under `vscode.authentication.account`. The Agent Host uses the provider/account/issuer tuple to separate client and bootstrap quota ownership before `/user` resolves a new token; GitHub still establishes the authoritative account identity. Token expiry alone does not reset that selection. The binding reconciles provenance during token lookup and acquisition as well as authentication events, so scoped-token fallback and expired-token pruning notify dependent consumers. Hosts and clients without this metadata remain supported with a conservative per-resource bootstrap cooldown. Sealed-token adapters that substitute a different credential omit the original token's provenance. @@ -217,7 +219,7 @@ The helper does not promise full Chromium parity: SOCKS4/4a, native NTLM, ordere The neutral [IPC contract](common/remote/ipc.ts) defines `ISharedProcessGitHubService.acquireAnonymousClient(signal)` and `acquireClient(authorization, signal)`. Both return promises of directly disposable domain clients, not reference wrappers. The [anonymous client](common/anonymous/anonymous.ts) exposes content reads, `gist.get`, and `search.issues`; the [account client](common/account/account.ts) adds full gist/search and core capabilities while staying pinned to its explicit authorization context. Default-account selection remains with the caller. -The acquisition signal governs acquiring a lease, not its subsequent lifetime. The wire acquisition and release commands identify a client-generated, connection-scoped lease ID so a cancelled acquisition can release it even if its reply is lost. Request messages carry that ID and an endpoint profile: `rest`, `graphql`, or `rawContent`. Content metadata and gist operations use the REST profile; public raw files use a separate `rawContent` provider. +The acquisition signal governs acquiring a lease, not its subsequent lifetime. Cancellation rejects acquisition even while the shared-process connection is pending; cleanup still uses the original connection when it becomes available. The wire acquisition and release commands identify a client-generated, connection-scoped lease ID so a cancelled acquisition can release it even if its reply is lost. Request messages carry that ID and an endpoint profile: `rest`, `graphql`, or `rawContent`. Content metadata and gist operations use the REST profile; public raw files use a separate `rawContent` provider. [Request providers](common/client/request.ts) use one request contract with JSON, text, and empty-response modes. Request bodies are objects before serialization and occupy a separate text slot in the IPC tuple afterward; response and error bodies likewise use separate text slots. The [remote provider](common/remote/ipcClient.ts) decodes successful JSON and invokes domain parsers in the calling process. No parser, `Headers`, or `Response` instance crosses IPC. diff --git a/src/vs/platform/github/client-inventory.md b/src/vs/platform/github/client-inventory.md index 58803a9da7e483..bf8e84b76f75f6 100644 --- a/src/vs/platform/github/client-inventory.md +++ b/src/vs/platform/github/client-inventory.md @@ -36,6 +36,7 @@ These are **current locations**, not proposed destinations. Browser workbench re | **Plugin marketplaces and plugin/MCP details** - [catalog](../../workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts#L625), [MCP README](../mcp/common/mcpGalleryService.ts#L949) | Raw GitHub marketplace definitions and READMEs; JSON/text | Anonymous raw-content requests; private repository discovery can switch to clone-based handling | Catalog refresh with 8h positive cache; details fetched on opening | Workbench; gallery service also has shared-process hosting | | **Browser plugin materialization (migrated)** - [browser repository client](../../workbench/contrib/chat/browser/pluginGitCommandService.ts) | Typed content ref resolution, recursive trees and blobs; base64 to files | Explicit existing GitHub grant, then anonymous, then explicitly requested repository session; update/checkouts never prompt | Install/update-triggered; SHA caching; staged extraction and bounded blob fan-out | Web workbench through local typed providers | | **Issue reporter** - [anonymous search](../../workbench/contrib/issue/browser/issueFormService.ts), [search/create](../../workbench/contrib/issue/browser/baseIssueReporterService.ts), [attachment transport](../native/electron-main/nativeHostMainService.ts#L909) | Issue search/create, repository lookup, upload policy/storage/confirmation; JSON and multipart bytes | Per-call anonymous client for GitHub search; GitHub token for writes; policy-issued storage fields for uploads | Debounced cancellable search with a ten-second request deadline; explicit submission, serial attachment uploads | Search through shared-process IPC on desktop and local providers on web; create remains separate; **attachment transfer in Electron main**; browser upload unsupported | +| **Slow-extension issue search (migrated)** - [performance action](../../workbench/contrib/extensions/electron-browser/extensionsSlowActions.ts) | Typed issue search; one result used with the total count to choose report/show action | Anonymous public GitHub client; no account lookup or sign-in | Manual performance action or automatic unresponsive-extension profiling; one ten-second budget for acquisition and search; per-call client disposal | Shared-process IPC on desktop | Copilot Connectors already has a [shared-process request proxy](../copilotConnectors/electron-browser/copilotConnectorsRequestService.ts). It is both a migration target and useful IPC precedent. diff --git a/src/vs/platform/github/common/remote/ipcClient.ts b/src/vs/platform/github/common/remote/ipcClient.ts index e656ec46933387..2ea10fed7afa87 100644 --- a/src/vs/platform/github/common/remote/ipcClient.ts +++ b/src/vs/platform/github/common/remote/ipcClient.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { raceCancellationError } from '../../../../base/common/async.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { CancellationError, isCancellationError, onUnexpectedError } from '../../../../base/common/errors.js'; import { Event } from '../../../../base/common/event.js'; @@ -50,8 +51,8 @@ export class GitHubChannelClient implements ISharedProcessGitHubService { try { const token = CancellationToken.fromAbortSignal(signal, lifetime); const resolved = authorization - ? parseClientResponse(await this.channel.call('acquireClient', { clientId, authorization }, token), parseAuthorization) - : await this.channel.call('acquireAnonymousClient', { clientId }, token); + ? parseClientResponse(await raceCancellationError(this.channel.call('acquireClient', { clientId, authorization }, token), token), parseAuthorization) + : await raceCancellationError(this.channel.call('acquireAnonymousClient', { clientId }, token), token); signal.throwIfAborted(); const controller = new AbortController(); diff --git a/src/vs/platform/github/test/common/githubChannel.test.ts b/src/vs/platform/github/test/common/githubChannel.test.ts index 9aefe72342cd25..975dfdbcb9bb74 100644 --- a/src/vs/platform/github/test/common/githubChannel.test.ts +++ b/src/vs/platform/github/test/common/githubChannel.test.ts @@ -9,7 +9,7 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { CancellationError, isCancellationError } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, DisposableStore, IReference, toDisposable } from '../../../../base/common/lifecycle.js'; -import { ClientConnectionEvent, IChannel, IPCServer } from '../../../../base/parts/ipc/common/ipc.js'; +import { ClientConnectionEvent, getDelayedChannel, IChannel, IPCServer } from '../../../../base/parts/ipc/common/ipc.js'; import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; import { mock } from '../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; @@ -503,6 +503,27 @@ suite('GitHub anonymous IPC clients', () => { assert.strictEqual(service.acquisitions, 0); }); + test('cancels acquisition while the shared-process connection is still pending', async () => { + const { connect, service } = setup(); + const { connection } = connect(); + const connected = new DeferredPromise(); + const remote = new GitHubChannelClient(getDelayedChannel(connected.p), store.add(new NullLogService())); + const controller = new AbortController(); + let cancelled = false; + const rejected = assert.rejects(remote.acquireAnonymousClient(controller.signal), isCancellationError).then(() => { cancelled = true; }); + + controller.abort(new CancellationError()); + await connection.flush(); + const cancelledBeforeConnection = cancelled; + await connected.complete(connection.channel); + await rejected; + await connection.flush(); + + assert.deepStrictEqual({ cancelledBeforeConnection, acquisitions: service.acquisitions, releases: service.releases }, { + cancelledBeforeConnection: true, acquisitions: 0, releases: 0, + }); + }); + test('releases an allocated lease when cancellation loses the acquisition reply', async () => { const { connect, service } = setup(); const { remote, connection } = connect(); @@ -1196,6 +1217,27 @@ suite('GitHub authenticated IPC clients', () => { assert.deepStrictEqual({ calls: broker.resolutions.length, scopes: service.scopes }, { calls: 0, scopes: 0 }); }); + test('cancels acquisition while the shared-process connection is still pending', async () => { + const { connect, service } = setup(); + const { connection, broker } = connect(); + const connected = new DeferredPromise(); + const remote = new GitHubChannelClient(getDelayedChannel(connected.p), store.add(new NullLogService())); + const controller = new AbortController(); + let cancelled = false; + const rejected = assert.rejects(remote.acquireClient(authorization, controller.signal), isCancellationError).then(() => { cancelled = true; }); + + controller.abort(new CancellationError()); + await connection.flush(); + const cancelledBeforeConnection = cancelled; + await connected.complete(connection.channel); + await rejected; + await connection.flush(); + + assert.deepStrictEqual({ cancelledBeforeConnection, resolutions: broker.resolutions, acquisitions: service.acquisitions, releases: service.releases }, { + cancelledBeforeConnection: true, resolutions: [], acquisitions: 0, releases: 0, + }); + }); + for (const ending of ['cancel', 'disconnect', 'grant change'] as const) { test(`does not resurrect an acquisition after ${ending} while the credential callback is pending`, async () => { const response = new DeferredPromise(); diff --git a/src/vs/platform/mcp/common/mcpCopilotConfiguration.ts b/src/vs/platform/mcp/common/mcpCopilotConfiguration.ts index 1074bfec28bd22..d6c39fa71ffedb 100644 --- a/src/vs/platform/mcp/common/mcpCopilotConfiguration.ts +++ b/src/vs/platform/mcp/common/mcpCopilotConfiguration.ts @@ -7,7 +7,8 @@ import { normalizeMcpServerConfiguration } from '../../agentPlugins/common/plugi import { IMcpServerConfiguration, McpServerType } from './mcpPlatformTypes.js'; export interface ICopilotLocalMcpServerConfiguration { - type: 'local'; + /** The canonical stdio type. The Copilot CLI also reads the legacy `local` alias, but other readers may not. */ + type: 'stdio'; command: string; args: string[]; tools: string[]; @@ -29,7 +30,7 @@ export type ICopilotMcpServerConfiguration = ICopilotLocalMcpServerConfiguration export function toCopilotMcpServerConfiguration(config: IMcpServerConfiguration, cwd = config.type === McpServerType.LOCAL ? config.cwd : undefined): ICopilotMcpServerConfiguration { if (config.type === McpServerType.LOCAL) { return { - type: 'local', + type: 'stdio', command: config.command, args: config.args ? [...config.args] : [], tools: ['*'], diff --git a/src/vs/platform/mcp/common/mcpResourceScannerService.ts b/src/vs/platform/mcp/common/mcpResourceScannerService.ts index 81a0b39407aac6..5e76109dce4e9c 100644 --- a/src/vs/platform/mcp/common/mcpResourceScannerService.ts +++ b/src/vs/platform/mcp/common/mcpResourceScannerService.ts @@ -470,13 +470,12 @@ export function getEditedMcpServerConfiguration(previous: IMcpServerConfiguratio /** * A server entry in the Copilot CLI's configuration, as {@link IMcpResourceScannerService.updateMcpServer} - * writes it. Unlike {@link toCopilotMcpServerConfiguration}, which targets the SDK, it uses the - * canonical `stdio` type (`local` is a legacy alias) and keeps `env` values as they are instead of - * converting them to strings. + * writes it. Unlike {@link toCopilotMcpServerConfiguration}, which targets the SDK, it keeps `env` + * values as they are instead of converting them to strings. */ function toCopilotGlobalMcpServerEntry(config: IMcpServerConfiguration): object { const entry = toCopilotMcpServerConfiguration(config); - return config.type === McpServerType.LOCAL ? { ...entry, type: McpServerType.LOCAL, env: config.env } : entry; + return config.type === McpServerType.LOCAL ? { ...entry, env: config.env } : entry; } /** diff --git a/src/vs/platform/mcp/test/common/mcpCopilotConfiguration.test.ts b/src/vs/platform/mcp/test/common/mcpCopilotConfiguration.test.ts index e9c0eac607fe51..e6980c470ec8fc 100644 --- a/src/vs/platform/mcp/test/common/mcpCopilotConfiguration.test.ts +++ b/src/vs/platform/mcp/test/common/mcpCopilotConfiguration.test.ts @@ -21,9 +21,9 @@ suite('McpCopilotConfiguration', () => { toCopilotMcpServerConfiguration({ type: McpServerType.REMOTE, url: 'https://example.com/mcp' }), toCopilotMcpServerConfiguration({ type: McpServerType.REMOTE, transport: 'sse', url: 'https://example.com/sse', headers: { Authorization: '$TOKEN' }, oauth: { clientId: 'client' } }), ], [ - { type: 'local', command: 'node', args: [], tools: ['*'] }, - { type: 'local', command: 'node', args: ['server.js'], tools: ['*'], env: { TOKEN: '$TOKEN', PORT: '3000' }, cwd: '/configured' }, - { type: 'local', command: 'node', args: [], tools: ['*'], cwd: '/resolved' }, + { type: 'stdio', command: 'node', args: [], tools: ['*'] }, + { type: 'stdio', command: 'node', args: ['server.js'], tools: ['*'], env: { TOKEN: '$TOKEN', PORT: '3000' }, cwd: '/configured' }, + { type: 'stdio', command: 'node', args: [], tools: ['*'], cwd: '/resolved' }, { type: 'http', url: 'https://example.com/mcp', tools: ['*'] }, { type: 'sse', url: 'https://example.com/sse', tools: ['*'], headers: { Authorization: '$TOKEN' }, oauthClientId: 'client' }, ]); diff --git a/src/vs/platform/mcp/test/common/mcpResourceScannerService.test.ts b/src/vs/platform/mcp/test/common/mcpResourceScannerService.test.ts index 93286ea552b3f4..672fed963edae7 100644 --- a/src/vs/platform/mcp/test/common/mcpResourceScannerService.test.ts +++ b/src/vs/platform/mcp/test/common/mcpResourceScannerService.test.ts @@ -76,7 +76,7 @@ suite('McpResourceScannerService', () => { lineEndings: content.includes('\r\n') && !content.replaceAll('\r\n', '').includes('\n'), names: Object.keys((await scanner.scanMcpServers(resource, undefined, format)).servers ?? {}), }, { - document: { custom: true, mcpServers: { keep: { type: 'ws', url: 'ws://localhost', tools: ['read'] }, invalid: null, two: { type: 'local', command: 'two', args: [], tools: ['*'] } } }, + document: { custom: true, mcpServers: { keep: { type: 'ws', url: 'ws://localhost', tools: ['read'] }, invalid: null, two: { type: 'stdio', command: 'two', args: [], tools: ['*'] } } }, lineEndings: true, names: ['two'], }); @@ -87,7 +87,7 @@ suite('McpResourceScannerService', () => { await scanner.removeMcpServers(['absent'], resource, undefined, format); assert.strictEqual(await fileService.exists(resource), false); await scanner.addMcpServers([server('new')], resource, undefined, format); - assert.deepStrictEqual(JSON.parse(await read()), { mcpServers: { new: { type: 'local', command: 'new', args: [], tools: ['*'] } } }); + assert.deepStrictEqual(JSON.parse(await read()), { mcpServers: { new: { type: 'stdio', command: 'new', args: [], tools: ['*'] } } }); }); for (const original of ['', 'null', '[]', '{ broken', '{"mcpServers":null}', '{"mcpServers":[]}', '{"mcpServers":"bad"}']) { diff --git a/src/vs/platform/policy/common/copilotManagedSettings.ts b/src/vs/platform/policy/common/copilotManagedSettings.ts index 4bd88a0e88aafd..97ac3010d9423a 100644 --- a/src/vs/platform/policy/common/copilotManagedSettings.ts +++ b/src/vs/platform/policy/common/copilotManagedSettings.ts @@ -8,6 +8,7 @@ import { IPolicyData } from '../../../base/common/defaultAccount.js'; import { ExtraKnownMarketplacesConfigDict, IExtraKnownMarketplaceEntry, extraKnownMarketplacesToConfigDict } from '../../../base/common/managedSettings.js'; import { IManagedSettingPolicyDefinition, IManagedSettingsPolicyDefinitions, ManagedSettingValue, ManagedSettingsData } from '../../../base/common/policy.js'; import { IStringDictionary } from '../../../base/common/collections.js'; +import { parse, ParseError } from '../../../base/common/json.js'; import { isEmptyObject, isObject, isString } from '../../../base/common/types.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; @@ -86,7 +87,7 @@ export const TemporaryDefaultToLocalHarnessSettingId = 'chat.temporaryDefaultToL /** * Enterprise-mandated sandbox floor (`sandbox.enabled` in the runtime's managed-settings schema). * The runtime owns composing and enforcing this floor — it is `force-on-wins`, so a managed `true` - * cannot be loosened by the user. VS Code only *reads* it to display Copilot sandbox state, + * cannot be loosened by the user. VS Code only *reads* it to decide which chat harness to offer, * and deliberately declares no configuration policy for it: the control is runtime-owned, and * mirroring it as a VS Code policy would invert ownership. */ @@ -305,10 +306,18 @@ export class NullManagedSettingsService implements IManagedSettingsService { } } -/** Detects permission settings without interpreting their runtime-owned values. */ -export function hasManagedPermissions(service: IManagedSettingsService): boolean { - const settings = service.getManagedSettings(); - return Object.keys(settings).some(key => (key === 'permissions' || key.startsWith('permissions.')) && settings[key] !== undefined); +/** Non-empty or malformed managed deny lists require runtime enforcement; rule interpretation remains runtime-owned. */ +export function hasManagedPermissionDenials(service: IManagedSettingsService): boolean { + const value = service.getManagedSettingValue('permissions.deny'); + if (value === undefined) { + return false; + } + if (typeof value !== 'string') { + return true; + } + const errors: ParseError[] = []; + const rules: unknown = parse(value, errors, { disallowComments: true, allowTrailingComma: false }); + return errors.length > 0 || !Array.isArray(rules) || rules.length > 0; } /** Whether an administrator has enabled the temporary Local harness escape hatch. */ @@ -316,9 +325,17 @@ export function isTemporaryDefaultToLocalHarnessEnabled(configurationService: IC return configurationService.inspect(TemporaryDefaultToLocalHarnessSettingId).policyValue === true; } -/** Only explicit Local disablement requires Agent Host, independently of runtime permission and sandbox settings. */ -export function requiresCopilotAgentHost(service: IManagedSettingsService): boolean { - return service.getManagedSettingValue(COPILOT_TEMPORARY_ALLOW_LOCAL_HARNESS_KEY) === false; +/** Whether effective policy requires Agent Host; explicit Local disablement overrides the temporary Local escape hatch. */ +export function requiresCopilotAgentHost(service: IManagedSettingsService, configurationService: IConfigurationService): boolean { + if (service.getManagedSettingValue(COPILOT_TEMPORARY_ALLOW_LOCAL_HARNESS_KEY) === false) { + return true; + } + const temporaryDefaultToLocal = configurationService?.inspect(TemporaryDefaultToLocalHarnessSettingId).policyValue + ?? service.getManagedSettingValue(COPILOT_TEMPORARY_DEFAULT_TO_LOCAL_HARNESS_KEY); + if (temporaryDefaultToLocal === true) { + return false; + } + return service.getManagedSettingValue(COPILOT_SANDBOX_ENABLED_KEY) === true || hasManagedPermissionDenials(service); } let managedModelValueCallback: ((policyData: IPolicyData) => ManagedSettingValue | undefined) | undefined; diff --git a/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts b/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts index 1ab4c171a52e52..b21e020e9e093a 100644 --- a/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts +++ b/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts @@ -8,14 +8,15 @@ import { IStringDictionary } from '../../../../base/common/collections.js'; import { IPolicyData } from '../../../../base/common/defaultAccount.js'; import { ManagedSettingsData } from '../../../../base/common/policy.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { collectManagedSettingsDefinitions, COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY, COPILOT_MODEL_KEY, COPILOT_OTEL_CAPTURE_IDENTITY_KEY, COPILOT_PERMISSION_RULES_KEYS, COPILOT_SANDBOX_ALLOWED_HOSTS_KEY, COPILOT_SANDBOX_ALLOW_BYPASS_KEY, COPILOT_SANDBOX_ALLOW_DEV_TOOL_ACCESS_KEY, COPILOT_SANDBOX_ALLOW_LOCAL_NETWORK_KEY, COPILOT_SANDBOX_ALLOW_OUTBOUND_KEY, COPILOT_SANDBOX_AUTH_GH_KEY, COPILOT_SANDBOX_AUTH_GIT_KEY, COPILOT_SANDBOX_DENIED_PATHS_KEY, COPILOT_SANDBOX_ENABLED_KEY, COPILOT_SANDBOX_LSP_SERVERS_KEY, COPILOT_SANDBOX_MCP_SERVERS_KEY, COPILOT_SANDBOX_READONLY_PATHS_KEY, COPILOT_SANDBOX_READWRITE_PATHS_KEY, COPILOT_TEMPORARY_DEFAULT_TO_LOCAL_HARNESS_KEY, COPILOT_TEMPORARY_ALLOW_LOCAL_HARNESS_KEY, COPILOT_TOP_LEVEL_MODEL_KEY, MANAGED_SETTINGS_CONTROL_DEFINITIONS, hasManagedPermissions, hasManagedSettingsDefinitions, IManagedSettingsService, NullManagedSettingsService, managedModelValue, managedSettingsDisabledValue, managedSettingValue, normalizeManagedSettings, projectManagedSettings, pickManagedSettings, requiresCopilotAgentHost, resolveForceRemoteSettingsRefresh } from '../../common/copilotManagedSettings.js'; +import { collectManagedSettingsDefinitions, COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY, COPILOT_MODEL_KEY, COPILOT_OTEL_CAPTURE_IDENTITY_KEY, COPILOT_PERMISSION_RULES_KEYS, COPILOT_SANDBOX_ALLOWED_HOSTS_KEY, COPILOT_SANDBOX_ALLOW_BYPASS_KEY, COPILOT_SANDBOX_ALLOW_DEV_TOOL_ACCESS_KEY, COPILOT_SANDBOX_ALLOW_LOCAL_NETWORK_KEY, COPILOT_SANDBOX_ALLOW_OUTBOUND_KEY, COPILOT_SANDBOX_AUTH_GH_KEY, COPILOT_SANDBOX_AUTH_GIT_KEY, COPILOT_SANDBOX_DENIED_PATHS_KEY, COPILOT_SANDBOX_ENABLED_KEY, COPILOT_SANDBOX_LSP_SERVERS_KEY, COPILOT_SANDBOX_MCP_SERVERS_KEY, COPILOT_SANDBOX_READONLY_PATHS_KEY, COPILOT_SANDBOX_READWRITE_PATHS_KEY, COPILOT_TEMPORARY_DEFAULT_TO_LOCAL_HARNESS_KEY, COPILOT_TEMPORARY_ALLOW_LOCAL_HARNESS_KEY, COPILOT_TOP_LEVEL_MODEL_KEY, MANAGED_SETTINGS_CONTROL_DEFINITIONS, hasManagedPermissionDenials, hasManagedSettingsDefinitions, IManagedSettingsService, NullManagedSettingsService, managedModelValue, managedSettingsDisabledValue, managedSettingValue, normalizeManagedSettings, projectManagedSettings, pickManagedSettings, requiresCopilotAgentHost, resolveForceRemoteSettingsRefresh } from '../../common/copilotManagedSettings.js'; import { PolicyDefinition } from '../../common/policy.js'; +import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; suite('Copilot managed settings projection', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - test('permission rules including empty lists retain their presence across delivery channels', () => { + test('only non-empty deny rules require Agent Host across delivery channels', () => { const read = (values: ManagedSettingsData): IManagedSettingsService => new class extends NullManagedSettingsService { override getManagedSettingValue(key: string) { return values[key]; } override getManagedSettings() { return values; } @@ -27,21 +28,21 @@ suite('Copilot managed settings projection', () => { return { empty, rules, watched: MANAGED_SETTINGS_CONTROL_DEFINITIONS[key], - absent: hasManagedPermissions(read({})), - emptyGate: hasManagedPermissions(read(empty)), - rulesGate: hasManagedPermissions(read(rules)), - overridden: hasManagedPermissions(read(pickManagedSettings(empty, rules, rules).values)), - server: hasManagedPermissions(read(pickManagedSettings({}, rules, {}).values)), - file: hasManagedPermissions(read(pickManagedSettings({}, {}, rules).values)), + absent: hasManagedPermissionDenials(read({})), + emptyGate: hasManagedPermissionDenials(read(empty)), + rulesGate: hasManagedPermissionDenials(read(rules)), + overridden: hasManagedPermissionDenials(read(pickManagedSettings(empty, rules, rules).values)), + server: hasManagedPermissionDenials(read(pickManagedSettings({}, rules, {}).values)), + file: hasManagedPermissionDenials(read(pickManagedSettings({}, {}, rules).values)), }; }); assert.deepStrictEqual(results, COPILOT_PERMISSION_RULES_KEYS.map(key => ({ empty: { [key]: '[]' }, rules: { [key]: '["Shell"]' }, watched: { type: 'string' }, - absent: false, emptyGate: true, rulesGate: true, overridden: true, server: true, file: true, + absent: false, emptyGate: false, rulesGate: key === 'permissions.deny', overridden: false, server: key === 'permissions.deny', file: key === 'permissions.deny', }))); }); - test('all permission settings retain their presence without requiring Agent Host', () => { + test('permission settings other than deny do not require Agent Host', () => { const values: ManagedSettingsData[] = [ normalizeManagedSettings({ permissions: {} }), normalizeManagedSettings({ telemetry: { enabled: true }, sandbox: { enabled: false } }), @@ -49,21 +50,28 @@ suite('Copilot managed settings projection', () => { normalizeManagedSettings({ permissions: { disableAssistedPermissionsMode: false } }), normalizeManagedSettings({ permissions: { disableBypassPermissionsMode: 'enable' } }), normalizeManagedSettings({ permissions: { limitTo: [] } }), + normalizeManagedSettings({ permissions: { allow: ['Shell'], ask: ['Read'] } }), normalizeManagedSettings({ permissions: { future: {} } }), normalizeManagedSettings({ permissions: { future: null } }), normalizeManagedSettings({ permissions: { defaultMode: 'manual', disableBypassPermissionsMode: 'disable' } }), { 'permissions.ask': 'not json' }, { 'permissions.allow': '{}' }, { 'permissions.deny': false }, ]; - assert.deepStrictEqual(values.map(bag => { - const service = new class extends NullManagedSettingsService { - override getManagedSettingValue(key: string) { return bag[key]; } - override getManagedSettings() { return bag; } - }(); - return { present: hasManagedPermissions(service), required: requiresCopilotAgentHost(service) }; - }), [false, false, true, true, true, true, true, true, true, true, true, true].map(present => ({ present, required: false }))); + assert.deepStrictEqual(values.map(bag => hasManagedPermissionDenials(new class extends NullManagedSettingsService { + override getManagedSettingValue(key: string) { return bag[key]; } + override getManagedSettings() { return bag; } + }())), [false, false, false, false, false, false, false, false, false, false, false, false, true]); + }); + + test('deny lists are empty only when they are valid empty JSON arrays', () => { + const values = ['[]', ' [ \n\t ] ', '["Shell"]', '[""]', '[null]', '[{}]', '{}', 'null', 'false', '""', '', 'not json', '[', '[] trailing', '[/*comment*/]', false, 0, undefined]; + assert.deepStrictEqual(values.map(value => hasManagedPermissionDenials(new class extends NullManagedSettingsService { + override getManagedSettingValue(key: string) { return key === 'permissions.deny' ? value : undefined; } + }())), [false, false, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, false]); }); - test('managed sandbox settings and permission rules do not require Copilot', () => { + test('only managed sandbox true or non-empty deny rules require Copilot', () => { + const configurationService = new TestConfigurationService(); + disposables.add(configurationService.onDidChangeConfigurationEmitter); const values: ManagedSettingsData[] = [ {}, { [COPILOT_SANDBOX_ENABLED_KEY]: true }, @@ -74,14 +82,20 @@ suite('Copilot managed settings projection', () => { { [COPILOT_SANDBOX_ENABLED_KEY]: false, 'permissions.ask': '[]' }, { [COPILOT_TEMPORARY_ALLOW_LOCAL_HARNESS_KEY]: true, [COPILOT_SANDBOX_ENABLED_KEY]: true }, { [COPILOT_TEMPORARY_ALLOW_LOCAL_HARNESS_KEY]: true, 'permissions.allow': '[]' }, + { [COPILOT_SANDBOX_ENABLED_KEY]: false, 'permissions.deny': '["Shell"]' }, + { [COPILOT_SANDBOX_ENABLED_KEY]: false, 'permissions.deny': '[]' }, + { [COPILOT_SANDBOX_ENABLED_KEY]: 'true' }, + { [COPILOT_SANDBOX_MCP_SERVERS_KEY]: true, [COPILOT_SANDBOX_ALLOWED_HOSTS_KEY]: '[]' }, ]; assert.deepStrictEqual(values.map(bag => requiresCopilotAgentHost(new class extends NullManagedSettingsService { override getManagedSettingValue(key: string) { return bag[key]; } override getManagedSettings() { return bag; } - }())), values.map(() => false)); + }(), configurationService)), [false, true, false, false, true, false, false, true, false, true, false, false, false]); }); - test('sandbox and permissions leave Local eligible independently of the temporary default', () => { + test('only a managed true Local escape hatch overrides required sandbox and permission routing', () => { + const configurationService = new TestConfigurationService(); + disposables.add(configurationService.onDidChangeConfigurationEmitter); const key = COPILOT_TEMPORARY_DEFAULT_TO_LOCAL_HARNESS_KEY; const declarations = collectManagedSettingsDefinitions({ ChatTemporaryDefaultToLocalHarness: { type: 'boolean', managedSettings: { [key]: { type: 'boolean' } } }, @@ -89,24 +103,26 @@ suite('Copilot managed settings projection', () => { const results = [undefined, false, true, 'true'].map(value => { const normalized = normalizeManagedSettings({ vscode: { temporaryDefaultToLocalHarness: value } }); const projected = projectManagedSettings(normalized, declarations); - const bag: ManagedSettingsData = { ...projected, [COPILOT_SANDBOX_ENABLED_KEY]: true, 'permissions.allow': '[]' }; + const bag: ManagedSettingsData = { ...projected, [COPILOT_SANDBOX_ENABLED_KEY]: true, 'permissions.deny': '["Shell"]' }; return { projected, required: requiresCopilotAgentHost(new class extends NullManagedSettingsService { override getManagedSettingValue(key: string) { return bag[key]; } override getManagedSettings() { return bag; } - }()), + }(), configurationService), }; }); assert.deepStrictEqual(results, [ - { projected: {}, required: false }, - { projected: { [key]: false }, required: false }, + { projected: {}, required: true }, + { projected: { [key]: false }, required: true }, { projected: { [key]: true }, required: false }, - { projected: {}, required: false }, + { projected: {}, required: true }, ]); }); test('only boolean false prohibits Local without permission or sandbox requirements', () => { + const configuration = new TestConfigurationService(); + disposables.add(configuration.onDidChangeConfigurationEmitter); const key = COPILOT_TEMPORARY_ALLOW_LOCAL_HARNESS_KEY; const results = [undefined, false, true, 'false', 0, null].map(value => { const bag = normalizeManagedSettings({ vscode: { temporaryAllowLocalHarness: value } }); @@ -115,7 +131,7 @@ suite('Copilot managed settings projection', () => { required: requiresCopilotAgentHost(new class extends NullManagedSettingsService { override getManagedSettingValue(key: string) { return bag[key]; } override getManagedSettings() { return bag; } - }()), + }(), configuration), }; }); assert.deepStrictEqual({ results, watched: MANAGED_SETTINGS_CONTROL_DEFINITIONS[key] }, { diff --git a/src/vs/platform/workspace/common/workspaceTrust.ts b/src/vs/platform/workspace/common/workspaceTrust.ts index 60f08c4fea16db..9622ca3e065696 100644 --- a/src/vs/platform/workspace/common/workspaceTrust.ts +++ b/src/vs/platform/workspace/common/workspaceTrust.ts @@ -62,6 +62,8 @@ export interface IWorkspaceTrustManagementService { /** Automatically trusts an exact scheme and authority for the registration's lifetime, without persisting user trust. */ registerTrustedAuthority(scheme: string, authority: string): IDisposable; + /** Returns explicit resolver or registration trust for an exact authority. Absence is treated as untrusted. */ + isAuthorityTrusted?(scheme: string, authority: string): boolean; getTrustedUris(): URI[]; setTrustedUris(uris: URI[]): Promise; diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index b4b84f3932c30b..7aedbd3a53c3d5 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -105,7 +105,7 @@ Host-published MCP items retain the runtime-reported configuration source throug The home page shows the original Overview until the default-off marketplace visibility flag and at least one marketplace source are enabled; it then shows unified Discover. With Marketplace visibility off, the legacy MCP and Plugin Available sections remain available. Switching surfaces disposes the previous surface and restores the current visible sections and harness context. Discover normalizes installed item-bearing sections from their existing owners and uses the platform `ICustomizationMarketplaceService` as its available catalog source. Catalog results are not installed customizations, do not contribute to customization counts, and do not imply compatibility with the active harness or permission to install. The UI and installation consumers depend on source-neutral contracts, not individual catalog backends. Marketplace-backed installation state and reversible install, repair, or uninstall operations remain owned by `ICustomizationMarketplaceInstallService`. -Marketplace composition supplies `ICustomizationMarketplaceProvider`s with unique provider IDs and user-visible source IDs. Multiple internal providers can belong to one contributed source; selecting that source queries all of its applicable providers while results and errors retain the contributed source ID. Per-source enablement and the query service remain independent for legacy management lists; only Discover presentation and Marketplace installation apply the separate visibility setting on top of enabled sources. The platform service contributes only existing platform registries such as MCP Gallery. The workbench composes configured Plugin marketplaces and Connectors, and an active harness may contribute the GitHub Feed through its SDK search provider. Only selected providers are instantiated and queried. Each provider owns transport, response validation, metadata normalization, and validation of any installation provenance. Resource identity includes the source ID, opaque identifier, and version, including for deduplication and pending installation state. Optional resource icons accept either one theme-independent URI or a `{ light, dark }` URI bundle; consumers resolve bundles against the active VS Code theme. +Marketplace composition supplies `ICustomizationMarketplaceProvider`s with unique provider IDs and user-visible source IDs. Multiple internal providers can belong to one contributed source; selecting that source queries all of its applicable providers while results and errors retain the contributed source ID. Per-source enablement and the query service remain independent for legacy management lists; only Discover presentation and Marketplace installation apply the separate visibility setting on top of enabled sources. The platform service contributes only existing platform registries such as MCP Gallery. The workbench composes configured Plugin marketplaces and Connectors, and an active harness may contribute the GitHub Feed through its SDK search provider. Only selected providers are instantiated and queried. Each provider owns transport, response validation, metadata normalization, and validation of any installation provenance. Resource identity includes the source ID, opaque identifier, and version, including for deduplication and pending installation state. Optional resource icons accept either one theme-independent URI or a `{ light, dark }` URI bundle; consumers resolve bundles against the active VS Code theme. GitHub Feed MCP resources may also carry bounded connector identifiers; Discover matches them against connector and connector-owned MCP server identifiers, promotes the matching Copilot connector as the primary result, and collapses its linked MCP entry behind an “alternative customization” disclosure by default. Among results made available by the enabled source settings, Discover orders Copilot Connector groups first, GitHub Feed MCP servers and plugins next, GitHub Feed skills after those, and resources from other marketplace sources last. Native result order remains stable within each tier. Harnesses that provide `marketplaceManagementProvider` own their marketplace inventory and mutations. The management UI uses that provider exclusively and does not merge `chat.plugins.marketplaces`; each available provider marketplace is also projected as an independently selectable Discover source backed by the provider's browse operation. Harnesses without the capability retain the VS Code configuration-backed marketplace flow. Runtime-backed providers remain authoritative for managed-policy enforcement. diff --git a/src/vs/sessions/SESSIONS_LIST.md b/src/vs/sessions/SESSIONS_LIST.md index 17611bf6f5ef44..315fea16b1e286 100644 --- a/src/vs/sessions/SESSIONS_LIST.md +++ b/src/vs/sessions/SESSIONS_LIST.md @@ -13,7 +13,7 @@ This specification defines stable placement and state-ownership rules. Row styli | Concern | Owner | |---------|-------| | Session catalog and lifecycle | `ISessionsManagementService` | -| Pin and per-sort ordering state | `ISessionsListModelService` | +| Pin, per-sort ordering, and nested chat ordering state | `ISessionsListModelService` | | Custom groups and membership | `ISessionGroupsService` | | Top-level group/workspace order | `ISessionSectionOrderService` | | Tree composition and presentation | `SessionsView` and `SessionsList` | @@ -95,15 +95,19 @@ The archived filter governs both archived sessions and archived nested chats; th ## Drag and drop -Drag and drop changes only list-owned presentation state or opens sessions through the appropriate service: +Drag and drop changes only list-owned presentation state, archives or restores sessions through the owning services, or opens sessions through the appropriate service. -- sessions may reorder within valid sections; -- sessions may move into user-created groups; -- non-archived sessions may move into the pinned section; +A session drop resolves to a destination placement: Pinned, a custom group, an ungrouped section, or Archived. Moving sessions into a placement applies only the state that placement precedence reads: it archives or restores, pins or unpins, and joins or leaves a custom group as needed. A session may enter an ungrouped section only if that is the section it renders in when ungrouped. + +- sessions may reorder within valid sections and move into Pinned, a custom group, or their own ungrouped section, which unpins or ungroups them as needed; +- dropping sessions on the archived section archives them with an undo affordance; dropping archived sessions elsewhere restores them; +- date sections derive from the sort key, so dropping on a date section header places sessions at the top of that section, and dropping below the last row places non-archived sessions at the end of the date sections; because date grouping also promotes recently updated sessions and caps Recent, a drop into a date section is offered only when the sessions will render in that section; +- dropping a session on a nested chat row places it after that chat's session; +- nested chats may reorder within their session, by dragging or with Move Up and Move Down. When moving chats by drag and drop is on and the provider can move a chat, the provider reorders or moves it and owns the resulting order, and the list drops any list-owned order of the sessions involved. Other chats use a list-owned chat order per sort mode; chats absent from that order keep their natural position; - user groups and workspace sections may reorder where the grouping mode allows; - dropping sessions on the Sessions grid opens them through `ISessionsService`. -Archived and fixed sections are not reorder targets. Multi-selection preserves relative order. +Archived sections and comparison groups keep a fixed order, and comparison group membership cannot change by dragging. Multi-selection preserves relative order. ## Reactive presentation @@ -115,7 +119,7 @@ Session facades may expose catalog-backed peer-chat identities, titles, interact ## Persistence -List presentation state is profile-scoped user state. This includes grouping, sorting, filtering, section and nested-session collapse, pins, custom groups, manual sort keys, and section order. A nested session starts expanded, and the user's later collapse or expansion choice persists across list and window recreation. Storage keys are private implementation details; other components change list state through the owning service API. +List presentation state is profile-scoped user state. This includes grouping, sorting, filtering, section and nested-session collapse, pins, custom groups, manual sort keys, nested chat order, and section order. A nested session starts expanded, and the user's later collapse or expansion choice persists across list and window recreation. Storage keys are private implementation details; other components change list state through the owning service API. ## Change policy diff --git a/src/vs/sessions/SESSION_COMPARISONS.md b/src/vs/sessions/SESSION_COMPARISONS.md index 9f851256185548..49c9de62f9a8c9 100644 --- a/src/vs/sessions/SESSION_COMPARISONS.md +++ b/src/vs/sessions/SESSION_COMPARISONS.md @@ -4,7 +4,7 @@ ## Scope -Session comparisons run the same task through multiple Sessions providers and preserve every implementation in an isolated worktree. The workflow is provider-neutral: comparison code uses `ISessionsManagementService`, while providers remain responsible for listing and resolving their own model identifiers, creating worktrees, and deleting sessions. +Session comparisons run the same task through multiple Sessions providers. Workspace-backed comparisons preserve every implementation in an isolated worktree. Folderless comparisons create independent workspace-less chats and compare responses side by side, without Judge or Synthesizer participants. The workflow is provider-neutral: comparison code uses `ISessionsManagementService`, while providers remain responsible for listing and resolving their own model identifiers, creating worktrees, and deleting sessions. The new-session composer offers comparison setup in the model picker, behind `sessions.chat.compareAgents.enabled`. The Compare Models toggle sits beside Search at the end of the picker's tab bar. The guided flow selects Attempts with checkboxes, then an optional Judge and an optional Synthesizer through Next. A comparison runs two to ten total attempts, each at one fixed model and configuration; a checked model contributes one attempt by default, or more when it has queued variants (below). Done and Next keep their places in the footer and are enabled only while two to ten attempts are queued; more can be queued, and the footer status then flags the limit until enough are cleared. The Judge and Synthesizer steps offer Back, and the last step offers only Done. Auto/routing models are excluded. Each checkable row keeps its model Details entry point (the same Effort Level and Context Size flyout the single-model picker uses), so an attempt, Judge, or Synthesizer model that supports configurable reasoning effort can be set before Done; opening Details for one row never selects or checks a different one. A checked Attempts row's Details also offers "Compare at another effort": one click per not-yet-queued configuration adds another attempt of that same model at that configuration (e.g. the same model once at High and once at Max), each removable independently; the row then names how many extra attempts it has queued, and the composer pill names each variant beside its model. Done saves the draft configuration, closes the picker, and returns focus to the prompt; the composer's model pill names the chosen attempt models directly (falling back to a count when a label cannot be resolved), while its Run N Attempts action launches it. @@ -29,7 +29,7 @@ Comparison groups use the standard bulk Mark All as Done action, confirmation, a Each comparison has one visible Sessions group containing all of its participants: -- **Attempt:** one uniquely identified run with one provider-local fixed model and one isolated worktree. Repeated runs have distinct participant IDs even when their configurations are identical, including two attempts of the same model at different configurations (a variant). +- **Attempt:** one uniquely identified run with one provider-local fixed model and, for workspace-backed comparisons, one isolated worktree. Repeated runs have distinct participant IDs even when their configurations are identical, including two attempts of the same model at different configurations (a variant). - **Judge:** optional model that starts after at least two successfully launched attempts reach a terminal state and at least one of them completed, and submits one structured verdict. An attempt that only dropped out of its provider's catalog holds judging until it returns or its deletion is confirmed. Omitting the Judge means no automatic evaluation or result panel. - **Synthesis:** optional new participant using the explicitly configured Synthesizer model. It requires a Judge verdict and an explicit user action, and never mutates an original attempt. Omitting the Synthesizer leaves review available without offering synthesis. @@ -38,7 +38,7 @@ The comparison service creates attempts directly and adds each launched particip ## Lifecycle invariants 1. The prompt, attachment entries and references, workspace, branch, and every participant's harness/model/effort/permission choice are frozen at launch. Referenced attachment contents are not copied or frozen and may change at their original URI. All roles inherit the current draft's harness and permission/mode choices, with separate role-specific model selections and supported model configuration. -2. Every harness must support worktree configuration, and the selected Git repository must have at least one commit and a remote. Model identifiers remain provider-local and are never matched across providers by identifier or display name. Reasoning effort is stored as model configuration and is offered only when the provider can scope that configuration to the new draft. Each harness stores the opaque permission identifier and display label advertised by its Sessions provider; the provider owns its native configuration mapping and must apply organization policy before the draft's first configuration resolution. A locked or unavailable draft permission blocks launch rather than silently selecting a different permission. +2. Workspace-backed comparisons require every harness to support worktree configuration and the selected Git repository to have at least one commit and a remote. Folderless comparisons require quick-chat support, omit workspace and isolation configuration, and route attempts through `createAndSendQuickChatRequest`; their persisted records have no workspace. Model catalogs for creation accept an absent workspace URI for these chats. Model identifiers remain provider-local and are never matched across providers by identifier or display name. Reasoning effort is stored as model configuration and is offered only when the provider can scope that configuration to the new draft. Each harness stores the opaque permission identifier and display label advertised by its Sessions provider; the provider owns its native configuration mapping and must apply organization policy before the draft's first configuration resolution. A locked or unavailable draft permission blocks launch rather than silently selecting a different permission. 3. Attempts launch concurrently. One launch failure is recorded without deleting successful attempts. If fewer than two attempts launch, comparison setup fails and any successful sessions remain available outside the comparison group. An attempt whose launch was interrupted by a window reload is recorded as a failed launch. The comparison service remains the single orchestration owner for Judge and synthesis creation; Agent Host does not infer ownership from client subscription state. Opening the comparison parent opens every available attempt together in a tiled Sessions grid, regardless of whether the Judge or a synthesis session is available. Judge and synthesis sessions remain directly accessible from their participant rows. 4. The Judge calls `readAttemptComparison` once to obtain the original task, successful participants, worktree locations, changed files, change summaries, and exact provider-owned transcript targets. Because terminal commands start in the Judge worktree, it explicitly changes to the manifest's exact attempt working directory for every command that inspects or validates that attempt. It reviews every attempt's diff, calls the existing `get_session_context` tool with those exact targets to inspect validation claims or other focused transcript evidence, and runs missing targeted validation when needed. It records whether each validation result came from the attempt report, a Judge run, unavailable evidence, or did not apply. It submits a recommendation, concise categorized rationale, attempt evidence, and conflicts through `completeAttemptComparison`, without custom synthesis decision sections. A rejected invalid verdict may be corrected and retried, but a successful verdict is not resubmitted. It does not discover sessions, guess references, create sessions, or modify attempts. 5. After the Judge submits a verdict, its chat remains open with a scorecard docked above the input. The header names the winning model and the Judge's one-line explanation, beside Open Attempt N and, when configured, Synthesize. Below it, every attempt is a row, recommended first: model and reasoning effort, total time, and provider-reported tokens, with thin meters to compare time and tokens, and an always-visible Open action for that attempt's session. Rows are identified by model name alone; the attempt number used in `Open Attempt N` and accessible labels is not shown visually. Expanding any row, including the winner, shows a compact validation strip for tests, build, lint, and diagnostics (each check with its result, and a hover for its source and what it verifies) beneath its narrative, followed by Open Attempt N. The recommended row's narrative is the four rationale points in this order: solution, comparison, validation, and code quality. Another row's narrative is what is worth keeping from it and its gaps. Each rationale point is bounded by the verdict tool schema so the result remains scannable. Opening the winner records a preference and opens its session without automatically opening its Changes editor; it does not apply changes to the user's working tree, and its split-button menu opens any other attempt. **Synthesize** starts recommended synthesis directly. Its split-button menu opens a persisted additional-instructions field. The field includes **Start Synthesis with Instructions** and supports Ctrl/Cmd+Enter, so entering requirements has an explicit submission path. diff --git a/src/vs/sessions/browser/parts/chatView.ts b/src/vs/sessions/browser/parts/chatView.ts index bbfe8fa6856440..85adcc2febe40a 100644 --- a/src/vs/sessions/browser/parts/chatView.ts +++ b/src/vs/sessions/browser/parts/chatView.ts @@ -95,6 +95,8 @@ export abstract class AbstractChatView extends Disposable implements ISerializab // no-op by default } + openModelComparison(): void { } + /** * Whether the view has a visible transcript turn to retain when a remote * host disconnects. New and unbound views intentionally report no content. diff --git a/src/vs/sessions/browser/parts/sessionView.ts b/src/vs/sessions/browser/parts/sessionView.ts index f0644dd70f5028..ac510b79eb11d0 100644 --- a/src/vs/sessions/browser/parts/sessionView.ts +++ b/src/vs/sessions/browser/parts/sessionView.ts @@ -372,6 +372,10 @@ export class SessionView extends Disposable implements ISerializableView, ISessi this._visibleStandaloneView?.focusHarnessPicker(); } + openModelComparison(): void { + this._visibleStandaloneView?.openModelComparison(); + } + /** * Starts an inline rename of the session title in the header. Returns * `false` when the header cannot host it (e.g. this view is hidden or the diff --git a/src/vs/sessions/common/agentHostSessionsProvider.ts b/src/vs/sessions/common/agentHostSessionsProvider.ts index 4581459465b99c..78dd8fb825ee51 100644 --- a/src/vs/sessions/common/agentHostSessionsProvider.ts +++ b/src/vs/sessions/common/agentHostSessionsProvider.ts @@ -108,6 +108,11 @@ export interface IAgentHostMcpServer { readonly name: string; /** Optional Connector catalog name for presentation only. */ readonly displayName?: string; + /** + * Canonical Copilot Connector that owns this server. Connector-owned servers authenticate with the + * session's GitHub credential, so they are authorized through Copilot Connectors, not an MCP token. + */ + readonly connectorName?: string; readonly source?: McpServerSource; /** * Plugin the host reports as the source of this server's configuration. Unlike @@ -163,18 +168,6 @@ export interface IAgentHostMcpServer { * Adds remote connection properties and dynamic session configuration. */ export interface IAgentHostSessionsProvider extends ISessionsProvider { - /** - * Whether the host can move the session under another session of this provider (Block 4): the host moves sessions - * (`vscode.sessionMove`) and the session is unarchived with one chat. Answered from list data alone; the host - * restores the session and refuses at move time when it can't move. - */ - canMoveSessionUnder(sessionId: string): boolean; - /** - * Moves the session under `destinationSessionId` (Block 4): its chat becomes a chat of the destination, placed - * after the destination's chat `after` (`undefined`: first), and the session ends. Rejects when the host refuses. - */ - moveSessionUnder(sessionId: string, destinationSessionId: string, after: URI | undefined): Promise; - // -- Remote Connection (optional, used by remote agent host providers) -- /** Connection status observable, present on remote providers. */ readonly connectionStatus?: IObservable; diff --git a/src/vs/sessions/common/sessionCommands.ts b/src/vs/sessions/common/sessionCommands.ts index 4b7d8478d5686e..8693ffccac5ba7 100644 --- a/src/vs/sessions/common/sessionCommands.ts +++ b/src/vs/sessions/common/sessionCommands.ts @@ -22,6 +22,9 @@ export const RENAME_CHAT_COMMAND_ID = 'sessions.chatCompositeBar.renameChat'; /** Archives one or more sessions. Registered in `sessionsViewActions.ts`. */ export const ARCHIVE_SESSION_COMMAND_ID = 'sessionsViewPane.archiveSession'; +/** Archives the given sessions and offers to undo it from the Sessions view. Registered in `sessionsViewActions.ts`. */ +export const ARCHIVE_SESSIONS_WITH_UNDO_COMMAND_ID = 'sessionsViewPane.archiveSessionsWithUndo'; + /** Archives a chat independently of its session. Registered in `sessionsViewActions.ts`. */ export const ARCHIVE_CHAT_COMMAND_ID = 'sessionsViewPane.archiveChat'; @@ -59,6 +62,7 @@ export const SESSION_GRID_FOCUS_COMMANDS = { export const FOCUS_NEW_SESSION_WORKSPACE_PICKER_COMMAND_ID = 'sessions.focusNewSessionWorkspacePicker'; export const FOCUS_NEW_SESSION_HARNESS_PICKER_COMMAND_ID = 'sessions.focusNewSessionHarnessPicker'; +export const COMPARE_SESSION_MODELS_COMMAND_ID = 'sessions.compareModels'; export const FOCUS_PREVIOUS_CHAT_GROUP_COMMAND_ID = 'sessions.focusPreviousChatGroup'; diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index 60e073b1daf338..976c0e0d3bc216 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -58,7 +58,7 @@ import { WorktreeCreatedTaskDispatcher, AGENT_HOST_RUN_WORKTREE_CREATED_TASKS_SE import { AGENT_SESSIONS_SCOPED_INPUT_HISTORY_SETTING } from './sessionsChatHistory.js'; import '../../sessions/browser/mobile/mobileOverlayContribution.js'; import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; -import { AGENTS_PICKER_IN_ATTACH_CONTEXT_MENU_SETTING, COLLAPSED_SESSION_OPTIONS_SHOW_ICONS_SETTING, COMPARE_AGENTS_ENABLED_SETTING, COMPARE_AGENTS_OPEN_IN_GRID_SETTING, EXPERIMENTAL_NEW_SESSION_COMPOSER_LAYOUT_SETTING, NEW_SESSION_COMPOSER_OPTIONS_EXPANDED_SETTING, NEW_SESSION_CONNECTOR_RECOMMENDATIONS_SETTING, NEW_SESSION_WELCOME_MESSAGES_SETTING, NEW_SESSION_WELCOME_NAME_SETTING, NEW_SESSION_WELCOME_PHRASES_SETTING } from '../common/constants.js'; +import { AGENTS_PICKER_IN_ATTACH_CONTEXT_MENU_SETTING, COLLAPSED_SESSION_OPTIONS_SHOW_ICONS_SETTING, COMPARE_AGENTS_ENABLED_SETTING, COMPARE_AGENTS_OPEN_IN_GRID_SETTING, COMPARE_AGENTS_SHOW_PICKER_HINT_SETTING, EXPERIMENTAL_NEW_SESSION_COMPOSER_LAYOUT_SETTING, NEW_SESSION_COMPOSER_OPTIONS_EXPANDED_SETTING, NEW_SESSION_CONNECTOR_RECOMMENDATIONS_SETTING, NEW_SESSION_WELCOME_MESSAGES_SETTING, NEW_SESSION_WELCOME_NAME_SETTING, NEW_SESSION_WELCOME_PHRASES_SETTING } from '../common/constants.js'; import { DesktopLayoutContext, IsPhoneLayoutContext, SessionsChatBackgroundAvailableContext, SessionsChatBackgroundImageConfiguredContext } from '../../../common/contextkeys.js'; import { SESSION_ACTIVITY_BAR_SETTING } from '../../../common/sessionConfig.js'; import { TOGGLE_SESSION_ACTIVITY_BAR_COMMAND_ID } from '../../../browser/layoutActions.js'; @@ -617,6 +617,14 @@ Registry.as(ConfigurationExtensions.Configuration).regis description: localize('sessions.chat.compareAgents.hideInactiveInputs', "When enabled, hides the chat input in inactive panes when three or more parallel comparison attempts are shown in a grid. This behavior is disabled when screen-reader optimized mode is active."), tags: ['experimental'], }, + [COMPARE_AGENTS_SHOW_PICKER_HINT_SETTING]: { + type: 'boolean', + default: false, + scope: ConfigurationScope.APPLICATION, + markdownDescription: localize('sessions.chat.compareAgents.showPickerHint', "Show the experimental Send Query to Multiple Models action at the bottom of the model picker. Requires `#sessions.chat.compareAgents.enabled#`."), + tags: ['experimental'], + experiment: { mode: 'auto' }, + }, [COMPARE_AGENTS_OPEN_IN_GRID_SETTING]: { type: 'boolean', default: true, diff --git a/src/vs/sessions/contrib/chat/browser/chatView.ts b/src/vs/sessions/contrib/chat/browser/chatView.ts index c17f8641d73b46..acbbe493b84539 100644 --- a/src/vs/sessions/contrib/chat/browser/chatView.ts +++ b/src/vs/sessions/contrib/chat/browser/chatView.ts @@ -187,6 +187,12 @@ export class NewChatView extends AbstractChatView { } } + override openModelComparison(): void { + if (this._widget instanceof NewChatWidget) { + this._widget.openModelComparison(); + } + } + override selectWorkspace(folderUri: URI, options?: ISelectWorkspaceOptions): WorkspaceSelectionResult { return this._widget instanceof NewChatWidget ? this._widget.selectWorkspace(folderUri, options) : 'notReady'; } diff --git a/src/vs/sessions/contrib/chat/browser/modelPicker.ts b/src/vs/sessions/contrib/chat/browser/modelPicker.ts index 1943fd6c1a0075..bedfc1e577d274 100644 --- a/src/vs/sessions/contrib/chat/browser/modelPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/modelPicker.ts @@ -4,13 +4,15 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable } from '../../../../base/common/lifecycle.js'; +import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import { autorun, derived, IObservable, ISettableObservable, observableValue } from '../../../../base/common/observable.js'; import { localize2 } from '../../../../nls.js'; import { BaseActionViewItem } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; -import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IWorkspaceTrustManagementService } from '../../../../platform/workspace/common/workspaceTrust.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; @@ -25,6 +27,11 @@ import { IAgentHostService } from '../../../../platform/agentHost/common/agentSe import { LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../common/agentHostSessionsProvider.js'; import { Menus } from '../../../browser/menus.js'; import { IsPhoneLayoutContext, SessionUsesCombinedConfigPickerContext } from '../../../common/contextkeys.js'; +import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; +import { COMPARE_SESSION_MODELS_COMMAND_ID } from '../../../common/sessionCommands.js'; +import { ModelComparisonAvailableContext, ModelComparisonPickerFocusedContext } from '../../../../workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerComparison.js'; +import { ISessionsPartService } from '../../../services/sessions/browser/sessionsPartService.js'; +import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { ISessionContext } from '../../../services/sessions/browser/sessionContext.js'; import { SessionStatus } from '../../../services/sessions/common/session.js'; import { ISessionModelSelection } from './sessionModelSelection.js'; @@ -117,6 +124,11 @@ export class ModelPicker extends Disposable { this._register(this._newChatModelPickerService.registerModelPicker({ getDomNode: () => this._container, open: () => this._modelPicker.openModelPicker(), + openComparison: () => { + if (this._selectionModel.comparison?.available.get()) { + this._modelPicker.show(undefined, { startComparison: true }); + } + }, switchToModel: modelIdentifier => this.switchToModel(modelIdentifier), })); @@ -193,6 +205,28 @@ export class ModelPicker extends Disposable { // -- Action -- +registerAction2(class extends Action2 { + constructor() { + const precondition = ContextKeyExpr.and(IsSessionsWindowContext, ChatContextKeys.enabled, ModelComparisonAvailableContext); + super({ + id: COMPARE_SESSION_MODELS_COMMAND_ID, + title: localize2('sessions.compareModels', "Compare Models"), + f1: true, + precondition, + keybinding: { + weight: KeybindingWeight.SessionsContrib, + primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.KeyM, + when: ContextKeyExpr.and(precondition, ContextKeyExpr.or(ChatContextKeys.inputHasFocus, ChatContextKeys.inChatComposer, ModelComparisonPickerFocusedContext)), + }, + }); + } + override run(accessor: ServicesAccessor): void { + const sessions = accessor.get(ISessionsService); + const parts = accessor.get(ISessionsPartService); + (parts.getFocusedSessionView() ?? parts.getSessionView(sessions.activeSession.get()?.sessionId))?.openModelComparison(); + } +}); + registerAction2(class extends Action2 { constructor() { super({ diff --git a/src/vs/sessions/contrib/chat/browser/newChatInput.ts b/src/vs/sessions/contrib/chat/browser/newChatInput.ts index 0be065469a8c6d..5a11c8984b6442 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInput.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInput.ts @@ -102,7 +102,7 @@ import { IChatSubmitRequestHandlerService } from '../../../../workbench/contrib/ import { isPhoneLayout } from '../../../browser/parts/mobile/mobileLayout.js'; import { INewChatModelPickerService, NewChatModelPickerService } from './newChatModelPicker.js'; import { ISessionModelSelection, SessionModelSelection } from './sessionModelSelection.js'; -import { IModelPickerComparison } from '../../../../workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerComparison.js'; +import { IModelPickerComparison, ModelComparisonAvailableContext } from '../../../../workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerComparison.js'; import { hasSendableModelSelection } from './sessionModelPickerState.js'; import { createNewSessionConfigToolbar, createNewSessionControlToolbar } from './newSessionConfigToolbars.js'; import { trackChatInputPickerFocus } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.js'; @@ -769,7 +769,10 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation // Scopes the notice focus command to this composer. Tracked on the whole // container rather than the editor, so the command can also toggle focus // back out of a notice once it is in one. - const composerFocusKey = ChatContextKeys.inChatComposer.bindTo(this._register(this.contextKeyService.createScoped(chatInputContainer))); + const composerContext = this._register(this.contextKeyService.createScoped(chatInputContainer)); + const composerFocusKey = ChatContextKeys.inChatComposer.bindTo(composerContext); + const comparisonAvailable = ModelComparisonAvailableContext.bindTo(composerContext); + this._register(autorun(reader => comparisonAvailable.set(this.options.modelPickerComparison?.available.read(reader) ?? false))); const composerFocusTracker = this._register(dom.trackFocus(chatInputContainer)); this._register(composerFocusTracker.onDidFocus(() => composerFocusKey.set(true))); this._register(composerFocusTracker.onDidBlur(() => composerFocusKey.set(false))); @@ -875,7 +878,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation overlayBackground: EDITOR_DRAG_AND_DROP_BACKGROUND, })).addOverlay(root, root); - this._createEditor(inputArea, editorOverflowWidgetsDomNode); + this._createEditor(inputArea, editorOverflowWidgetsDomNode, composerContext); const inputHasContent = observableFromEvent(this, this._editor.onDidChangeModelContent, () => this._editor.getValue().length > 0); this._register(this.chatPetWidgetService.register(this, { parent: chatInputContainer, @@ -1096,14 +1099,14 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation }); } - private _createEditor(container: HTMLElement, overflowWidgetsDomNode: HTMLElement): void { + private _createEditor(container: HTMLElement, overflowWidgetsDomNode: HTMLElement, composerContext: IContextKeyService): void { const editorContainer = this._editorContainer = dom.append(container, dom.$('.sessions-chat-editor')); const minHeight = this.options.minEditorHeight ?? MIN_EDITOR_HEIGHT; editorContainer.style.height = `${minHeight}px`; // Create scoped context key service and register history navigation // BEFORE creating the editor, so the editor's context key scope is a child - const inputScopedContextKeyService = this._register(this.contextKeyService.createScoped(container)); + const inputScopedContextKeyService = this._register(composerContext.createScoped(container)); const { historyNavigationBackwardsEnablement, historyNavigationForwardsEnablement } = this._register(registerAndCreateHistoryNavigationContext(inputScopedContextKeyService, this)); this._historyNavigationBackwardsEnablement = historyNavigationBackwardsEnablement; this._historyNavigationForwardsEnablement = historyNavigationForwardsEnablement; diff --git a/src/vs/sessions/contrib/chat/browser/newChatModelPicker.ts b/src/vs/sessions/contrib/chat/browser/newChatModelPicker.ts index 36fc8a907d4754..98f7bd3b061e39 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatModelPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatModelPicker.ts @@ -11,6 +11,7 @@ export const INewChatModelPickerService = createDecorator void; + readonly openComparison?: () => void; readonly switchToModel: (modelIdentifier: string) => boolean; } diff --git a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts index 4aa47b4b5673f9..c85a2028b87b18 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts @@ -74,7 +74,7 @@ import { AgentsWindowUsage } from '../../../../workbench/contrib/chat/common/age import { INewSessionComposerService, NewSessionWorkspacePreselectionSource } from './newSessionComposerService.js'; import { Menus } from '../../../browser/menus.js'; import { getAdditionalFolderContextId, getAdditionalRepositoryContextId } from '../common/newChatContextIds.js'; -import { AGENTS_PICKER_IN_ATTACH_CONTEXT_MENU_SETTING, COLLAPSED_SESSION_OPTIONS_SHOW_ICONS_SETTING, COMPARE_AGENTS_ENABLED_SETTING, COMPARE_AGENTS_OPEN_IN_GRID_SETTING, EXPERIMENTAL_NEW_SESSION_COMPOSER_LAYOUT_SETTING, NEW_SESSION_COMPOSER_OPTIONS_EXPANDED_SETTING, NEW_SESSION_WELCOME_MESSAGES_SETTING, NEW_SESSION_WELCOME_NAME_SETTING, NEW_SESSION_WELCOME_PHRASES_SETTING, UNIFIED_WORKSPACE_PICKER_SETTING } from '../common/constants.js'; +import { AGENTS_PICKER_IN_ATTACH_CONTEXT_MENU_SETTING, COLLAPSED_SESSION_OPTIONS_SHOW_ICONS_SETTING, COMPARE_AGENTS_ENABLED_SETTING, COMPARE_AGENTS_OPEN_IN_GRID_SETTING, COMPARE_AGENTS_SHOW_PICKER_HINT_SETTING, EXPERIMENTAL_NEW_SESSION_COMPOSER_LAYOUT_SETTING, NEW_SESSION_COMPOSER_OPTIONS_EXPANDED_SETTING, NEW_SESSION_WELCOME_MESSAGES_SETTING, NEW_SESSION_WELCOME_NAME_SETTING, NEW_SESSION_WELCOME_PHRASES_SETTING, UNIFIED_WORKSPACE_PICKER_SETTING } from '../common/constants.js'; import { getNewSessionWelcomePhrases, INewSessionWelcomeMessagesConfiguration } from '../common/welcomePhrases.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { getSessionComparisonWorkspaceError, ISessionComparisonHarness, ISessionComparisonService } from '../../../services/sessions/common/sessionComparison.js'; @@ -368,8 +368,12 @@ export class NewChatWidget extends Disposable { const session = this._session.read(reader); session?.workspace.read(reader); session?.loading.read(reader); - return !this._isQuickChatComposer.read(reader) && this._shouldShowComparisonAction(); - }), comparisonConfigResolving, modelId => this.languageModelsService.lookupLanguageModel(modelId)?.name)); + session?.isQuickChat?.read(reader); + return this._shouldShowComparisonAction(); + }), comparisonConfigResolving, modelId => this.languageModelsService.lookupLanguageModel(modelId)?.name, + derived(this, reader => !this._isQuickChatComposer.read(reader)), + observableFromEvent(this, Event.filter(this.configurationService.onDidChangeConfiguration, event => event.affectsConfiguration(COMPARE_AGENTS_SHOW_PICKER_HINT_SETTING)), + () => this.configurationService.getValue(COMPARE_AGENTS_SHOW_PICKER_HINT_SETTING)))); const canSendRequest = derived(reader => { const session = this._session.read(reader); if (!session) { @@ -447,7 +451,7 @@ export class NewChatWidget extends Disposable { let comparisonHarness: string | undefined; this._register(autorun(reader => { const session = this._session.read(reader); - const harness = session ? JSON.stringify([session.providerId, session.sessionType]) : undefined; + const harness = session ? JSON.stringify([session.providerId, session.sessionType, session.isQuickChat?.read(reader)]) : undefined; if (harness !== comparisonHarness) { this._comparisonSelection.reset(); comparisonHarness = harness; @@ -1545,6 +1549,9 @@ export class NewChatWidget extends Disposable { const selectedFolderUri = this._workspacePicker.selectedFolderUri; const providerIsAgentHost = !!provider && isAgentHostProvider(provider); const resolvingConfig = session && providerIsAgentHost ? provider.isSessionConfigResolving(session.sessionId, session.mainChat.get().resource).get() : false; + if (session?.isQuickChat?.get()) { + return this._compareAgentsEnabled.get() && providerIsAgentHost && provider.supportsQuickChats === true && !resolvingConfig; + } const sessionMatchesSelectedFolder = !!selectedFolderUri && !!session?.workspace.get()?.folders.some(folder => isEqual(folder.root, selectedFolderUri)); if (!sessionMatchesSelectedFolder) { return false; @@ -1593,6 +1600,10 @@ export class NewChatWidget extends Disposable { this._newChatInput.sessionTypePicker.showPicker(); } + openModelComparison(): void { + this._newChatInput.modelPicker?.openComparison?.(); + } + /** Restores an explicit user choice, or uses the experiment-controlled initial state. */ private _restoreSessionOptionsExpanded(): void { const storedExpanded = this.storageService.getBoolean(SESSION_OPTIONS_EXPANDED_STORAGE_KEY, StorageScope.PROFILE); @@ -1705,18 +1716,20 @@ export class NewChatWidget extends Disposable { if (!this._comparisonSelection.configured.get()) { throw new Error(localize('comparisonPicker.finishSetup', "Finish configuring the comparison in the model picker.")); } - const workspace = this._workspacePicker.selectedFolderUri; - if (!workspace) { + const isQuickChat = session.isQuickChat?.get() === true; + const workspace = isQuickChat ? undefined : this._workspacePicker.selectedFolderUri; + if (!isQuickChat && !workspace) { throw new Error(localize('comparisonPicker.workspaceRequired', "Select a workspace for the comparison.")); } - const branch = this._getComparisonBranch(session); - const workspaceError = getSessionComparisonWorkspaceError(branch, getComparisonHasGitRemote(session, this._workspacePicker.selectedResolved?.workspace, workspace)); + const branch = workspace ? this._getComparisonBranch(session) : undefined; + const workspaceError = workspace ? getSessionComparisonWorkspaceError(branch, getComparisonHasGitRemote(session, this._workspacePicker.selectedResolved?.workspace, workspace)) : undefined; if (workspaceError) { throw new Error(workspaceError); } const provider = this.sessionsProvidersService.getProvider(session.providerId); - const type = this.sessionsManagementService.getSessionTypesForFolder(workspace).find(type => - type.providerId === session.providerId && type.sessionType.id === session.sessionType && type.sessionType.supportsWorktreeConfiguration); + const types = workspace ? this.sessionsManagementService.getSessionTypesForFolder(workspace) : this.sessionsManagementService.getQuickChatSessionTypes(); + const type = types.find(type => + type.providerId === session.providerId && type.sessionType.id === session.sessionType && (!workspace || type.sessionType.supportsWorktreeConfiguration)); if (!provider || !type) { throw new Error(localize('comparisonPicker.worktreesRequired', "The selected agent no longer supports worktree comparisons.")); } @@ -1726,7 +1739,7 @@ export class NewChatWidget extends Disposable { } const resolveHarness = (modelId: string, configurationOverride?: Readonly>): ISessionComparisonHarness => { const resolved = this._newChatInput.resolveComparisonModel(modelId); - if (!resolved?.sessionType.supportsWorktreeConfiguration) { + if (!resolved || workspace && !resolved.sessionType.supportsWorktreeConfiguration) { throw new Error(localize('comparisonPicker.modelUnavailable', "A selected comparison model is no longer available. Update the model picker selection.")); } const harnessPermission = provider.getComparisonPermissionOptionForSession?.(session.sessionId, resolved.sessionType.id) diff --git a/src/vs/sessions/contrib/chat/browser/newSessionComposerService.ts b/src/vs/sessions/contrib/chat/browser/newSessionComposerService.ts index ff228707d89436..0ac081640c1b8a 100644 --- a/src/vs/sessions/contrib/chat/browser/newSessionComposerService.ts +++ b/src/vs/sessions/contrib/chat/browser/newSessionComposerService.ts @@ -65,6 +65,7 @@ export interface INewSessionPromptOptionsController { export interface INewSessionComposerPicker { getDomNode(): HTMLElement | undefined; open(): void; + openComparison?(): void; } export interface INewSessionComposer { diff --git a/src/vs/sessions/contrib/chat/browser/sessionComparisonModelSelection.ts b/src/vs/sessions/contrib/chat/browser/sessionComparisonModelSelection.ts index 3364c179d6eba2..f53cbfd62deda0 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionComparisonModelSelection.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionComparisonModelSelection.ts @@ -8,6 +8,7 @@ import { autorun, constObservable, derived, IObservable, observableValue, transa import { localize } from '../../../../nls.js'; import { IModelPickerComparison, IModelPickerComparisonState, IModelPickerComparisonVariant } from '../../../../workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerComparison.js'; import { SESSION_COMPARISON_MAX_ATTEMPTS, SESSION_COMPARISON_MIN_ATTEMPTS } from '../../../services/sessions/common/sessionComparison.js'; +import { COMPARE_SESSION_MODELS_COMMAND_ID } from '../../../common/sessionCommands.js'; type ComparisonStep = 'attempts' | 'judge' | 'synthesizer'; @@ -128,6 +129,7 @@ export class SessionComparisonModelSelection extends Disposable implements IMode private readonly _step = observableValue(this, 'attempts'); readonly label = localize('comparisonPicker.compare', "Compare Models"); + readonly startCommandId = COMPARE_SESSION_MODELS_COMMAND_ID; readonly enabled = derived(this, reader => !!this._committed.read(reader)); readonly configured = this.enabled; readonly attempts = derived(this, reader => getAttempts(this._committed.read(reader))); @@ -137,8 +139,6 @@ export class SessionComparisonModelSelection extends Disposable implements IMode readonly summary = derived(this, reader => this.enabled.read(reader) ? getAttemptsSummaryText(this.attempts.read(reader), this._getModelLabel) : undefined); - /** The composer shows each attempt's own icon instead of the joined name list. */ - readonly summaryModelIds = derived(this, reader => this.enabled.read(reader) ? this.attemptModelIds.read(reader) : undefined); readonly state = derived(this, reader => { const draft = this._draft.read(reader); if (!draft || !this.available.read(reader)) { @@ -154,7 +154,9 @@ export class SessionComparisonModelSelection extends Disposable implements IMode title: step === 'attempts' ? localize('comparisonPicker.attempts', "Attempts") : step === 'judge' ? localize('comparisonPicker.judge', "Judge") : localize('comparisonPicker.synthesizer', "Synthesizer"), - description: step === 'attempts' ? localize('comparisonPicker.attemptsDescription', "Select two or more models to run one prompt in parallel. Compare results or have them judged and combined.") + description: step === 'attempts' ? this._reviewAvailable.read(reader) + ? localize('comparisonPicker.attemptsDescription', "Select two or more models to run one prompt in parallel. Compare results or have them judged and combined.") + : localize('comparisonPicker.chatAttemptsDescription', "Select two or more models to run one prompt in parallel and compare their responses side by side.") : step === 'judge' ? localize('comparisonPicker.judgeDescription', "Select a model to review the attempts and pick a winner. Skip this step to compare them yourself.") : localize('comparisonPicker.synthesizerDescription', "Select a model to combine the best parts of each attempt. Synthesis starts only when you ask."), summary: getAttemptsSummaryText(getAttempts(draft), this._getModelLabel), @@ -162,9 +164,9 @@ export class SessionComparisonModelSelection extends Disposable implements IMode multiple: step === 'attempts', maxSelections: step === 'attempts' ? Number.POSITIVE_INFINITY : 1, canGoBack: step !== 'attempts', - canGoNext: valid && (step === 'attempts' || step === 'judge' && judge !== undefined), + canGoNext: this._reviewAvailable.read(reader) && valid && (step === 'attempts' || step === 'judge' && judge !== undefined), canFinish: valid, - hasNextStep: step !== 'synthesizer', + hasNextStep: this._reviewAvailable.read(reader) && step !== 'synthesizer', status: step === 'attempts' ? getAttemptsStatus(attemptCount) : undefined, }; }); @@ -173,6 +175,8 @@ export class SessionComparisonModelSelection extends Disposable implements IMode readonly available: IObservable, configurationResolving: IObservable = constObservable(false), private readonly _getModelLabel?: (modelId: string) => string | undefined, + private readonly _reviewAvailable: IObservable = constObservable(true), + readonly showPickerHint: IObservable = constObservable(false), ) { super(); this._register(autorun(reader => { diff --git a/src/vs/sessions/contrib/chat/browser/sessionModelSelection.ts b/src/vs/sessions/contrib/chat/browser/sessionModelSelection.ts index c6838d04cffec5..bc2e6d4243b855 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionModelSelection.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionModelSelection.ts @@ -254,14 +254,14 @@ export class SessionModelSelection extends Disposable implements ISessionModelSe const session = this._activeSession; const provider = this._activeProvider; const workspace = session?.workspace.get(); - if (!session || !provider || !workspace || !provider.getModelsSnapshotForCreation) { + if (!session || !provider || !provider.getModelsSnapshotForCreation) { return undefined; } const entry = this._getComparisonModels().find(entry => entry.presentation.identifier === modelIdentifier); if (!entry) { return undefined; } - const resolution = provider.getModelsSnapshotForCreation(workspace.uri, entry.sessionType.id, entry.model.identifier).desiredModelResolution; + const resolution = provider.getModelsSnapshotForCreation(workspace?.uri, entry.sessionType.id, entry.model.identifier).desiredModelResolution; return resolution.kind === 'available' ? { sessionType: entry.sessionType, model: resolution.model } : undefined; } @@ -273,7 +273,7 @@ export class SessionModelSelection extends Disposable implements ISessionModelSe const session = this._activeSession; const provider = this._activeProvider; const workspace = session?.workspace.get(); - if (!session || !provider || !workspace || !provider.getModelsSnapshotForCreation) { + if (!session || !provider || !provider.getModelsSnapshotForCreation || !workspace && !session.isQuickChat?.get()) { return []; } const models = new Map(); - for (const sessionType of provider.getSessionTypes(workspace.uri)) { - for (const model of provider.getModelsSnapshotForCreation(workspace.uri, sessionType.id).models) { + for (const sessionType of workspace ? provider.getSessionTypes(workspace.uri) : provider.sessionTypes) { + for (const model of provider.getModelsSnapshotForCreation(workspace?.uri, sessionType.id).models) { models.set(model.identifier, { presentation: { ...model, diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index 8c060b60a24e77..910ee22b0b5223 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -137,6 +137,8 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat if (configurationService.getValue(COMPARE_AGENTS_ENABLED_SETTING)) { content.push(localize('sessionsChat.compareAgentsSetup', "Comparison setup is a working copy until you activate Done. Escape or clicking outside the picker discards setup changes and keeps your previous single-model or configured comparison ready to send. Unfinished setup does not resume when you reopen the picker. Done commits your choices and changes the model label to N Attempts and Send to Run N Attempts. Reopening a configured comparison starts a working copy of those choices.")); content.push(localize('sessionsChat.compareAgentsConfiguration', "Comparison selections are kept while draft permissions or mode are updating. Run N Attempts is unavailable until the update finishes.")); + content.push(localize('sessionsChat.compareAgentsDiscovery', "The model picker offers Compare Models in the tab bar. Use Tab to reach it and Enter or Space to begin setup. Use Compare Models{0} from the new-session input or model picker to open comparison setup directly. Enable the experimental sessions.chat.compareAgents.showPickerHint setting to also show Send Query to Multiple Models at the bottom with your configured shortcut. The tab-bar toggle remains available to return to single-model selection.", '')); + content.push(localize('sessionsChat.compareAgentsFolderless', "In a Chat with no workspace, comparisons run the prompt in separate workspace-less chats. Select at least two attempts, activate Done, then Run N Attempts to compare their responses side by side. Folderless comparisons do not offer Judge or Synthesizer steps.")); content.push(localize('sessionsChat.compareAgents', "In the new-session input, open the model picker and activate Compare Models. In Attempts, check two to ten distinct models. Attempts inherit the current workspace, branch, agent, and permissions. Done and Next stay disabled until two to ten models are checked, and the footer shows how many are selected. Use Tab to reach Done and Next. In Judge, optionally select a model to review the attempts and recommend a winner. Leave Judge unselected and activate Done to run attempts without review. With a Judge selected, activate Next to choose an optional Synthesizer. Without a Synthesizer, review is available but synthesis is not. Activate a selected model again to clear it. Back preserves your choices. Done returns to the composer; activate Run N Attempts to launch. Escape closes the picker without launching. Open the comparison parent in the Sessions list to open every available attempt in a resizable grid. With two attempt panes, both chat inputs remain visible. With three or more, only the active attempt pane shows its chat input; focus another attempt pane to show its input. Screen-reader optimized mode keeps every attempt input visible. After the Judge finishes, its chat shows a scorecard: the winning model with the Judge's explanation, then a row for each attempt, recommended first, with a summary of its validation checks such as Tests passed or Not run, total time, and tokens. Use Tab to reach the Checks column header to hear what the checks cover. Focus the comparison result and use Open Accessible View{0} to read its verdict and attempt metrics as plain text. Use Tab to reach the attempt rows and Up Arrow or Down Arrow to move between them, then press Enter or Space to expand a row with the Judge's reasoning, what is worth keeping, gaps, each check's result, and Open Attempt N. Each row also has an Open button after its metrics that opens that attempt's session. Activate Open Attempt to open the recommended attempt, or use its adjacent dropdown to open another attempt. When synthesis is available, Synthesize starts the Judge's default plan. Open its More Actions menu and choose Additional Synthesis Instructions to enter requirements that are remembered and applied to recommended synthesis. Activate Start Synthesis with Instructions, or press Ctrl+Enter (Cmd+Enter on macOS) in that field, to start the recommended plan with those requirements. The additional instructions guide a new synthesis session and do not modify the original attempts.", '')); content.push(localize('sessionsChat.compareAgentsGroupActions', "Use the comparison header's Mark All as Done action to mark its sessions done, with the same confirmation and Undo as other groups. When every participant is done, the automatically created group is removed. Undo restores the group and its sessions. Delete Group remains available from the comparison header context menu.")); content.push(localize('sessionsChat.compareAgentsTokenWarning', "Running a comparison uses tokens for each session. The Run N Attempts button shows this warning immediately on hover.")); @@ -255,6 +257,7 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.goForward', "Go forward through visited sessions and views{0}. Reopening New Session or a custom view moves its single history entry to the most recent position.", '')); content.push(localize('sessionsChat.navigatePreviousSession', "Navigate to the previous session in the list{0}.", '')); content.push(localize('sessionsChat.navigateNextSession', "Navigate to the next session in the list{0}.", '')); + content.push(localize('sessionsChat.moveNestedChat', "To reorder a session's nested chats in the Sessions list, focus a nested chat and invoke Move Up{0} or Move Down{1}, or choose them from its context menu. Focus stays on the moved chat.", '', '')); content.push(localize('sessionsChat.renameSession', "To rename a session inline, focus its row in the Sessions list and invoke Rename{0}, double-click its title, or open its context menu and choose Rename. Type the new title, then press Enter to confirm or Escape to cancel. From the main chat transcript or input, invoking Rename edits the header title inline when it is visible and opens a prompt otherwise.", ``)); content.push(localize('sessionsChat.renameChat', "When Rename is available for a non-main chat, focus its tab or nested row in the Sessions list and invoke Rename{0}, or double-click its title, to rename it inline. Type the new title, then press Enter to confirm or Escape to cancel. From the chat transcript or input, invoking Rename opens a prompt instead.", ``)); content.push(localize('sessionsChat.archiveSession', "To archive or mark one or more sessions as done, focus them in the Sessions list and invoke Archive or Mark as Done{0}.", ``)); diff --git a/src/vs/sessions/contrib/chat/common/constants.ts b/src/vs/sessions/contrib/chat/common/constants.ts index ce7b9488c713b2..f2e9fa79c8c6ff 100644 --- a/src/vs/sessions/contrib/chat/common/constants.ts +++ b/src/vs/sessions/contrib/chat/common/constants.ts @@ -10,6 +10,7 @@ export { NEW_SESSION_IN_AGENTS_WINDOW_COMMAND_ID as NEW_SESSION_ACTION_ID } from export const UNIFIED_WORKSPACE_PICKER_SETTING = ChatConfiguration.UnifiedWorkspacePicker; export { COMPARE_AGENTS_ENABLED_SETTING }; export const COMPARE_AGENTS_OPEN_IN_GRID_SETTING = 'sessions.chat.compareAgents.openInGrid'; +export const COMPARE_AGENTS_SHOW_PICKER_HINT_SETTING = 'sessions.chat.compareAgents.showPickerHint'; export const EXPERIMENTAL_NEW_SESSION_COMPOSER_LAYOUT_SETTING = 'sessions.chat.experimental.newSessionComposerLayout'; export const NEW_SESSION_COMPOSER_OPTIONS_EXPANDED_SETTING = 'sessions.chat.experimental.newSessionComposerOptionsExpanded'; export const AGENTS_PICKER_IN_ATTACH_CONTEXT_MENU_SETTING = 'sessions.chat.experimental.agentsPickerInAttachContextMenu'; diff --git a/src/vs/sessions/contrib/chat/test/browser/modelPicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/modelPicker.test.ts index c0cebdf1cdc748..f015ab16328b01 100644 --- a/src/vs/sessions/contrib/chat/test/browser/modelPicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/modelPicker.test.ts @@ -5,12 +5,17 @@ import assert from 'assert'; import { Event } from '../../../../../base/common/event.js'; +import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; +import { decodeKeybinding } from '../../../../../base/common/keybindings.js'; +import { OS } from '../../../../../base/common/platform.js'; import { constObservable, observableValue } from '../../../../../base/common/observable.js'; -import { mock } from '../../../../../base/test/common/mock.js'; +import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { isIMenuItem, MenuRegistry } from '../../../../../platform/actions/common/actions.js'; +import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { ContextKeyService } from '../../../../../platform/contextkey/browser/contextKeyService.js'; +import { KeybindingsRegistry } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IWorkspaceTrustManagementService } from '../../../../../platform/workspace/common/workspaceTrust.js'; @@ -21,6 +26,12 @@ import { IChatSessionsService } from '../../../../../workbench/contrib/chat/comm import { MockChatSessionsService } from '../../../../../workbench/contrib/chat/test/common/mockChatSessionsService.js'; import { ILanguageModelChatMetadataAndIdentifier } from '../../../../../workbench/contrib/chat/common/languageModels.js'; import { IChatEntitlementService } from '../../../../../workbench/services/chat/common/chatEntitlementService.js'; +import { IsSessionsWindowContext } from '../../../../../workbench/common/contextkeys.js'; +import { ModelComparisonAvailableContext, ModelComparisonPickerFocusedContext } from '../../../../../workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerComparison.js'; +import { COMPARE_SESSION_MODELS_COMMAND_ID } from '../../../../common/sessionCommands.js'; +import { SessionView } from '../../../../browser/parts/sessionView.js'; +import { ISessionsPartService } from '../../../../services/sessions/browser/sessionsPartService.js'; +import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { Menus } from '../../../../browser/menus.js'; import { IsPhoneLayoutContext, SessionUsesCombinedConfigPickerContext } from '../../../../common/contextkeys.js'; import { ISessionContext, SessionContext } from '../../../../services/sessions/browser/sessionContext.js'; @@ -80,6 +91,58 @@ suite('ModelPicker selectability', () => { suite('ModelPicker automation toolbar', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + test('comparison shortcut requires the Agents window, AI, comparison availability and composer or picker focus', () => { + const rule = KeybindingsRegistry.getDefaultKeybindings().find(rule => rule.command === COMPARE_SESSION_MODELS_COMMAND_ID); + assert.ok(rule); + const context = disposables.add(new ContextKeyService(new TestConfigurationService())); + const sessionsWindow = IsSessionsWindowContext.bindTo(context); + const aiEnabled = ChatContextKeys.enabled.bindTo(context); + const available = ModelComparisonAvailableContext.bindTo(context); + const input = ChatContextKeys.inputHasFocus.bindTo(context); + const composer = ChatContextKeys.inChatComposer.bindTo(context); + const picker = ModelComparisonPickerFocusedContext.bindTo(context); + const matches = (window: boolean, ai: boolean, canCompare: boolean, inputFocused: boolean, composerFocused: boolean, pickerFocused: boolean) => { + sessionsWindow.set(window); + aiEnabled.set(ai); + available.set(canCompare); + input.set(inputFocused); + composer.set(composerFocused); + picker.set(pickerFocused); + return context.contextMatchesRules(rule.when ?? undefined); + }; + assert.deepStrictEqual({ + keybinding: rule.keybinding?.equals(decodeKeybinding(KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.KeyM, OS)), + input: matches(true, true, true, true, false, false), + composer: matches(true, true, true, false, true, false), + picker: matches(true, true, true, false, false, true), + elsewhere: matches(true, true, true, false, false, false), + unavailable: matches(true, true, false, true, false, false), + aiDisabled: matches(true, false, true, true, false, false), + editorWindow: matches(false, true, true, true, false, false), + }, { + keybinding: true, + input: true, composer: true, picker: true, elsewhere: false, + unavailable: false, aiDisabled: false, editorWindow: false, + }); + + }); + + test('comparison command targets the focused session view rather than the last registered composer', () => { + const instantiationService = disposables.add(new TestInstantiationService()); + const calls: string[] = []; + const focusedView = upcastPartial({ openModelComparison: () => calls.push('focused') }); + const activeView = upcastPartial({ openModelComparison: () => calls.push('active') }); + instantiationService.stub(ISessionsPartService, upcastPartial({ + getFocusedSessionView: () => focusedView, + getSessionView: () => activeView, + })); + instantiationService.stub(ISessionsService, upcastPartial({ activeSession: constObservable(undefined) })); + const command = CommandsRegistry.getCommand(COMPARE_SESSION_MODELS_COMMAND_ID); + assert.ok(command); + instantiationService.invokeFunction(command.handler); + assert.deepStrictEqual(calls, ['focused']); + }); + test('contributes the model picker inside the automation prompt with AI and phone gating', () => { const item = MenuRegistry.getMenuItems(Menus.AutomationsDialogInputToolbar).find(item => isIMenuItem(item) && item.command.id === 'sessions.modelPicker'); assert.ok(item && isIMenuItem(item)); diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts index 16f39cd197bc8c..bcfb03d9c904e5 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.test.ts @@ -2360,12 +2360,13 @@ suite('NewChatWidget', () => { ]); }); - for (const { runs, navigationFails, openInGrid } of [ - { runs: 2, navigationFails: false, openInGrid: true }, - { runs: 10, navigationFails: true, openInGrid: true }, - { runs: 3, navigationFails: false, openInGrid: false }, + for (const { runs, navigationFails, openInGrid, quickChat } of [ + { runs: 2, navigationFails: false, openInGrid: true, quickChat: false }, + { runs: 10, navigationFails: true, openInGrid: true, quickChat: false }, + { runs: 3, navigationFails: false, openInGrid: false, quickChat: false }, + { runs: 2, navigationFails: false, openInGrid: true, quickChat: true }, ]) { - test(`launches ${runs} repeated attempts with independent IDs and the draft permissions`, async () => { + test(`launches ${runs} repeated attempts with independent IDs and the draft permissions (quick chat: ${quickChat})`, async () => { const resolving = observableValue('resolving', false); const selection = disposables.add(new SessionComparisonModelSelection(derived(reader => !resolving.read(reader)), resolving)); selection.start(); @@ -2376,15 +2377,18 @@ suite('NewChatWidget', () => { ...(i === 1 ? { serviceTier: 'priority' } : {}), }, `Attempt ${i + 1}`); } - selection.next(); - selection.select('codex-judge'); - selection.next(); - selection.select('copilot-synthesizer'); + if (!quickChat) { + selection.next(); + selection.select('codex-judge'); + selection.next(); + selection.select('copilot-synthesizer'); + } selection.finish(); const workspace = URI.file('/workspace'); const session = upcastPartial({ sessionId: 'draft', sessionType: 'copilotcli', providerId: LOCAL_AGENT_HOST_PROVIDER_ID, mode: constObservable({ id: 'file:///agents/reviewer.agent.md', kind: 'agent' }), + isQuickChat: constObservable(quickChat), }); let options: IStartSessionComparisonOptions | undefined; let fail = true; @@ -2417,7 +2421,7 @@ suite('NewChatWidget', () => { label: sessionType === 'copilotcli' ? 'Copilot' : sessionType === 'claude' ? 'Claude' : 'Codex', icon: Codicon.robot, authRequirement: SessionTypeAuthRequirement.None, - supportsWorktreeConfiguration: true, + supportsWorktreeConfiguration: !quickChat, }, model: upcastPartial({ identifier: id, @@ -2468,6 +2472,7 @@ suite('NewChatWidget', () => { }) }, sessionsManagementService: { + getQuickChatSessionTypes: () => [{ providerId: LOCAL_AGENT_HOST_PROVIDER_ID, sessionType: { id: 'copilotcli' } }], getSessionTypesForFolder: () => [{ providerId: session.providerId, sessionType: { id: session.sessionType, label: 'Copilot', supportsWorktreeConfiguration: true } }], discardNewSession: (draft: IActiveSession) => { assert.strictEqual(draft, session); @@ -2526,7 +2531,7 @@ suite('NewChatWidget', () => { judge: options.judgeHarness?.modelId, synthesizer: options.synthesisHarness?.modelId, evaluatorHarnesses: [options.judgeHarness?.sessionTypeId, options.synthesisHarness?.sessionTypeId], evaluatorModes: [options.judgeHarness?.modeId, options.synthesisHarness?.modeId], - workspace: options.workspace.toString(), branch: options.branch, attachments: options.attachedContext, + workspace: options.workspace?.toString(), branch: options.branch, attachments: options.attachedContext, enabled: selection.enabled.get(), navigation, }, { models: Array(runs).fill('claude-model'), harnesses: Array(runs).fill('claude'), uniqueIds: runs, permissions: Array(runs).fill('bypassPermissions'), @@ -2535,17 +2540,57 @@ suite('NewChatWidget', () => { { thinkingLevel: 'medium', contextSize: 1_000_000 }, ...Array.from({ length: runs - 1 }, (_, index) => ({ thinkingLevel: 'medium', contextSize: 1_000_000, testAttempt: index + 1 })), ], - evaluatorConfigurations: [ + evaluatorConfigurations: quickChat ? [undefined, undefined] : [ { thinkingLevel: 'medium', contextSize: 1_000_000 }, { thinkingLevel: 'medium', contextSize: 1_000_000, serviceTier: 'priority' }, ], - evaluatorModes: [undefined, 'autopilot'], - judge: 'codex-judge', synthesizer: 'copilot-synthesizer', evaluatorHarnesses: ['codex', 'copilotcli'], workspace: workspace.toString(), branch: 'feature', + evaluatorModes: quickChat ? [undefined, undefined] : [undefined, 'autopilot'], + judge: quickChat ? undefined : 'codex-judge', synthesizer: quickChat ? undefined : 'copilot-synthesizer', + evaluatorHarnesses: quickChat ? [undefined, undefined] : ['codex', 'copilotcli'], workspace: quickChat ? undefined : workspace.toString(), branch: quickChat ? undefined : 'feature', attachments: [attachment], enabled: false, navigation: !openInGrid ? ['discardDraft'] : navigationFails ? ['openGrid'] : ['openGrid', 'discardDraft'], }); }); } + test('offers folderless comparison only for quick-chat-capable, ready agent hosts while enabled', () => { + const session = upcastPartial({ + sessionId: 'session', + mainChat: constObservable(upcastPartial({ resource: URI.parse('agent-host-copilot:/session') })), + providerId: LOCAL_AGENT_HOST_PROVIDER_ID, + isQuickChat: constObservable(true), + }); + const enabled = observableValue('enabled', true); + const resolving = observableValue('resolving', false); + let supportsQuickChats = true; + const harness: IComparisonActionVisibilityHarness = { + _compareAgentsEnabled: enabled, + _pendingPreferredUpgrade: { value: undefined }, + _newSessionCreation: { value: undefined }, + _session: constObservable(session), + _workspacePicker: { selectedResolved: undefined }, + sessionsProvidersService: { + getProvider: () => ({ + id: LOCAL_AGENT_HOST_PROVIDER_ID, + get supportsQuickChats() { return supportsQuickChats; }, + getCreateSessionConfig: () => undefined, + isSessionConfigResolving: () => resolving, + }), + }, + _getComparisonBranch: getComparisonBranch, + }; + const ready = shouldShowComparisonAction.call(harness); + resolving.set(true, undefined); + const pending = shouldShowComparisonAction.call(harness); + resolving.set(false, undefined); + supportsQuickChats = false; + const unsupported = shouldShowComparisonAction.call(harness); + supportsQuickChats = true; + enabled.set(false, undefined); + assert.deepStrictEqual({ ready, pending, unsupported, disabled: shouldShowComparisonAction.call(harness) }, { + ready: true, pending: false, unsupported: false, disabled: false, + }); + }); + test('hides comparison action when selected folder has no git repository', () => { const workspace = upcastPartial({ folders: [{ diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionComparisonModelPicker.fixture.ts b/src/vs/sessions/contrib/chat/test/browser/sessionComparisonModelPicker.fixture.ts index 35216f04d0ea85..f148a3cc25b0b9 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionComparisonModelPicker.fixture.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionComparisonModelPicker.fixture.ts @@ -9,12 +9,14 @@ import { renderPicker } from '../../../../../workbench/test/browser/componentFix import { ComponentFixtureContext, defineComponentFixture, defineThemedFixtureGroup } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; import { SessionComparisonModelSelection } from '../../browser/sessionComparisonModelSelection.js'; -type ComparisonPickerState = 'empty' | 'attempts' | 'judge' | 'synthesizer'; +type ComparisonPickerState = 'single' | 'empty' | 'attempts' | 'judge' | 'synthesizer' | 'folderless'; function renderComparisonPicker(context: ComponentFixtureContext, state: ComparisonPickerState): Promise { - const selection = context.disposableStore.add(new SessionComparisonModelSelection(constObservable(true))); - selection.start(); - if (state !== 'empty') { + const selection = context.disposableStore.add(new SessionComparisonModelSelection(constObservable(true), constObservable(false), undefined, constObservable(state !== 'folderless'), constObservable(true))); + if (state !== 'single') { + selection.start(); + } + if (state !== 'empty' && state !== 'single') { selection.select('copilot/gpt-5-5'); selection.select('copilot/claude-sonnet-5'); selection.select('copilot/gemini-3-5-flash'); @@ -31,6 +33,8 @@ function renderComparisonPicker(context: ComponentFixtureContext, state: Compari } export default defineThemedFixtureGroup({ path: 'sessions/compareModels/' }, { + SingleModelPicker: defineComponentFixture({ additionalThemes: ['darkHighContrast', 'lightHighContrast'], render: context => renderComparisonPicker(context, 'single') }), + FolderlessAttempts: defineComponentFixture({ render: context => renderComparisonPicker(context, 'folderless') }), NoAttemptsSelected: defineComponentFixture({ additionalThemes: ['darkHighContrast', 'lightHighContrast'], render: context => renderComparisonPicker(context, 'empty') }), AttemptsSelected: defineComponentFixture({ additionalThemes: ['darkHighContrast', 'lightHighContrast'], render: context => renderComparisonPicker(context, 'attempts') }), JudgeSelected: defineComponentFixture({ render: context => renderComparisonPicker(context, 'judge') }), diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionComparisonModelSelection.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionComparisonModelSelection.test.ts index 42eaabd3c642ae..e8f4675a826bf7 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionComparisonModelSelection.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionComparisonModelSelection.test.ts @@ -60,6 +60,26 @@ suite('SessionComparisonModelSelection', () => { assert.deepStrictEqual(selection.attemptModelIds.get(), Array.from({ length: 10 }, (_, index) => `model-${index}`)); }); + test('folderless comparisons finish after Attempts without offering review steps', () => { + const selection = store.add(new SessionComparisonModelSelection(constObservable(true), constObservable(false), undefined, constObservable(false))); + selection.start(); + selectAttempts(selection, 'first', 'second'); + const state = selection.state.get(); + assert.throws(() => selection.next(), /not ready/); + selection.finish(); + assert.deepStrictEqual({ + canFinish: state?.canFinish, + canGoNext: state?.canGoNext, + hasNextStep: state?.hasNextStep, + attempts: selection.attemptModelIds.get(), + judge: selection.judgeModelId.get(), + synthesizer: selection.synthesizerModelId.get(), + }, { + canFinish: true, canGoNext: false, hasNextStep: false, + attempts: ['first', 'second'], judge: undefined, synthesizer: undefined, + }); + }); + test('a selected model can add variant attempts at other configurations, counted toward Done/Next and run alongside the default attempt', () => { const selection = store.add(new SessionComparisonModelSelection(constObservable(true))); selection.start(); diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionModelSelection.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionModelSelection.test.ts index 33d27340a627ee..a962ec0df967ae 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionModelSelection.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionModelSelection.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; -import { autorun, derived, ISettableObservable, observableValue, transaction } from '../../../../../base/common/observable.js'; +import { autorun, constObservable, derived, ISettableObservable, observableValue, transaction } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; @@ -248,63 +248,72 @@ suite('SessionModelSelection', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - test('offers every provider harness model only to comparison selection', () => { - const workspaceUri = URI.file('/workspace'); - const workspace: ISessionWorkspace = { - uri: workspaceUri, - label: 'workspace', - icon: Codicon.folder, - folders: [], - requiresWorkspaceTrust: false, - isVirtualWorkspace: false, - }; - const testSession = createSession('provider', SessionStatus.Untitled, first.identifier); - const session = { ...testSession.session, workspace: observableValue('workspace', workspace) }; - const provider = disposables.add(createProvider('provider')); - const copilot = { ...first, metadata: { ...first.metadata, targetChatSessionType: 'agent-host-copilot' } }; - const claude = { ...second, identifier: 'claude/second', metadata: { ...second.metadata, targetChatSessionType: 'agent-host-claude' } }; - const codex = { ...second, identifier: 'codex/second', metadata: { ...second.metadata, targetChatSessionType: 'agent-host-codex' } }; - provider.models = [copilot]; - provider.getSessionTypes = () => [ - { id: 'copilot', label: 'Copilot', icon: Codicon.copilot, authRequirement: SessionTypeAuthRequirement.GitHub }, - { id: 'claude', label: 'Claude', icon: Codicon.commentDiscussion, authRequirement: SessionTypeAuthRequirement.None }, - { id: 'codex', label: 'Codex', icon: Codicon.code, authRequirement: SessionTypeAuthRequirement.None }, - ]; - provider.getModelsSnapshotForCreation = (_workspace, sessionType, desiredModelId) => { - const models = sessionType === 'copilot' ? [copilot] : sessionType === 'claude' ? [claude] : [codex]; - return { - models, - desiredModelResolution: desiredModelId - ? { kind: 'available', model: models[0] } - : { kind: 'notRequested' }, - modelTarget: `agent-host-${sessionType}`, + for (const quickChat of [false, true]) { + test(`offers every provider harness model only to comparison selection (quick chat: ${quickChat})`, () => { + const workspaceUri = URI.file('/workspace'); + const workspace: ISessionWorkspace = { + uri: workspaceUri, + label: 'workspace', + icon: Codicon.folder, + folders: [], + requiresWorkspaceTrust: false, + isVirtualWorkspace: false, }; - }; - const selection = disposables.add(new SessionModelSelection( - observableValue('session', session), - {}, - createProvidersService([provider]), - disposables.add(new InMemoryStorageService()), - createConfigurationService(), - disposables.add(new NullLogService()), - )); - assert.deepStrictEqual({ - ordinary: selection.state.get().models.map(model => model.identifier), - comparison: selection.getComparisonModels().map(model => [model.identifier, model.metadata.modelGroup?.id]), - resolvedClaude: selection.resolveComparisonModel(claude.identifier), - }, { - ordinary: [copilot.identifier], - comparison: [ - [copilot.identifier, 'copilot'], - [claude.identifier, 'claude'], - [codex.identifier, 'codex'], - ], - resolvedClaude: { - sessionType: provider.getSessionTypes(workspaceUri)[1], - model: claude, - }, + const testSession = createSession('provider', SessionStatus.Untitled, first.identifier); + const session = { + ...testSession.session, + workspace: observableValue('workspace', quickChat ? undefined : workspace), + isQuickChat: constObservable(quickChat), + }; + const provider = disposables.add(createProvider('provider')); + const copilot = { ...first, metadata: { ...first.metadata, targetChatSessionType: 'agent-host-copilot' } }; + const claude = { ...second, identifier: 'claude/second', metadata: { ...second.metadata, targetChatSessionType: 'agent-host-claude' } }; + const codex = { ...second, identifier: 'codex/second', metadata: { ...second.metadata, targetChatSessionType: 'agent-host-codex' } }; + provider.models = [copilot]; + const sessionTypes = [ + { id: 'copilot', label: 'Copilot', icon: Codicon.copilot, authRequirement: SessionTypeAuthRequirement.GitHub }, + { id: 'claude', label: 'Claude', icon: Codicon.commentDiscussion, authRequirement: SessionTypeAuthRequirement.None }, + { id: 'codex', label: 'Codex', icon: Codicon.code, authRequirement: SessionTypeAuthRequirement.None }, + ]; + provider.getSessionTypes = () => sessionTypes; + Object.defineProperty(provider, 'sessionTypes', { value: sessionTypes }); + provider.getModelsSnapshotForCreation = (_workspace, sessionType, desiredModelId) => { + assert.strictEqual(_workspace, quickChat ? undefined : workspaceUri); + const models = sessionType === 'copilot' ? [copilot] : sessionType === 'claude' ? [claude] : [codex]; + return { + models, + desiredModelResolution: desiredModelId + ? { kind: 'available', model: models[0] } + : { kind: 'notRequested' }, + modelTarget: `agent-host-${sessionType}`, + }; + }; + const selection = disposables.add(new SessionModelSelection( + observableValue('session', session), + {}, + createProvidersService([provider]), + disposables.add(new InMemoryStorageService()), + createConfigurationService(), + disposables.add(new NullLogService()), + )); + assert.deepStrictEqual({ + ordinary: selection.state.get().models.map(model => model.identifier), + comparison: selection.getComparisonModels().map(model => [model.identifier, model.metadata.modelGroup?.id]), + resolvedClaude: selection.resolveComparisonModel(claude.identifier), + }, { + ordinary: [copilot.identifier], + comparison: [ + [copilot.identifier, 'copilot'], + [claude.identifier, 'claude'], + [codex.identifier, 'codex'], + ], + resolvedClaude: { + sessionType: provider.getSessionTypes(workspaceUri)[1], + model: claude, + }, + }); }); - }); + } test('managed models seed new chats, preserve available existing choices and replace unavailable ones', () => { const managed = { ...second, metadata: { ...second.metadata, managedModelDefaults: { model: true, keys: [] } } }; diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts index 780a193a6b1f48..25404fc60e307d 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts @@ -161,6 +161,10 @@ function createMockProvider(id: string, opts?: { deleteSession: async () => { }, deleteSessions: async () => { }, deleteChat: async () => true, + canMoveChat: () => false, + moveChat: async () => { }, + canMoveSessionUnder: () => false, + moveSessionUnder: async () => { }, createNewChat: async () => { throw new Error('Not implemented'); }, forkChat: async () => { throw new Error('Not implemented'); }, createSideChat: async () => { throw new Error('Not implemented'); }, 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 627a70d62d3c06..fd750915f470e7 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionsChatAccessibilityHelp.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionsChatAccessibilityHelp.test.ts @@ -837,6 +837,8 @@ suite('SessionsChatAccessibilityHelp', () => { optionalSynthesizer: enabledProvider.provideContent().includes('Without a Synthesizer, review is available but synthesis is not'), done: enabledProvider.provideContent().includes('Done returns to the composer'), configRefresh: enabledProvider.provideContent().includes('Comparison selections are kept while draft permissions or mode are updating.'), + discovery: enabledProvider.provideContent().includes('Send Query to Multiple Models at the bottom'), + folderless: enabledProvider.provideContent().includes('Folderless comparisons do not offer Judge or Synthesizer steps.'), cancelSetup: enabledProvider.provideContent().includes('Escape or clicking outside the picker discards setup changes'), opensAttemptsGrid: enabledProvider.provideContent().includes('open every available attempt in a resizable grid'), twoPaneInputs: enabledProvider.provideContent().includes('With two attempt panes, both chat inputs remain visible'), @@ -865,6 +867,8 @@ suite('SessionsChatAccessibilityHelp', () => { disabled: false, enabled: true, doneAndNextGated: true, optionalJudge: true, optionalSynthesizer: true, done: true, configRefresh: true, + discovery: true, + folderless: true, cancelSetup: true, opensAttemptsGrid: true, twoPaneInputs: true, 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 354d27befc39c3..c203c5f1be6188 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 @@ -151,6 +151,8 @@ The host moves a chat between sessions by removing it from one session's catalog A VS Code host can also move a session whose default chat is its only chat under another session (`vscode/moveSession`, advertised by `vscode.sessionMove`; the host publishes `_meta['vscode.movable']` only for sessions it has loaded, so the provider doesn't consult it). The provider offers it through `canMoveSessionUnder` and `moveSessionUnder` for an unarchived single-chat session when the capability is present, from list data alone, so other hosts never see it. The host restores an unloaded source at move time and refuses there when it can't move. The moved chat becomes a peer of the destination and the session ends; its views follow it like any moved chat. +The provider moves individual chats with AHP `moveChat` through `canMoveChat` and `moveChat`: only a chat whose `ChatSummary.movable` the host set can be a source, and the destination (a position in its own session, another session of the provider, or a new session) is resolved to host resources from the provider's recorded sessions and chats, never from client URIs. A host refusal (`AgentChatMoveRefusal`) rejects with a user-facing explanation instead of the host's diagnostic message. + ## Draft and send lifecycle `NewSession` represents an untitled draft before the backend session is committed. @@ -215,6 +217,8 @@ The host persists ordered aggregate workspace roots independently of each provid 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. +While `chat.agentHost.experimental.prewarmSessions` is on, the isolation choice stays local until Send. The eagerly created backend draft then uses the folder isolation that the host schema advertises, without the worktree base branch, so the host can pre-warm it; when no isolation schema is cached yet, eager creation waits for the draft's first config resolution. The first request then sends the picked isolation and base branch with the draft's config, and the host creates a picked worktree on that send. + 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/agentHostChatMoveErrors.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostChatMoveErrors.ts new file mode 100644 index 00000000000000..3522c714d2ae76 --- /dev/null +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostChatMoveErrors.ts @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../../nls.js'; +import { AgentChatMoveRefusal, AgentChatMoveRefusedError } from '../../../../../platform/agentHost/common/agentHostChatMove.js'; +import { ProtocolError } from '../../../../../platform/agentHost/common/state/sessionProtocol.js'; +import { SessionMoveRefusedError } from '../../../../services/sessions/common/sessionsProvider.js'; + +const refusals: ReadonlySet = new Set([ + AgentChatMoveRefusal.Disabled, + AgentChatMoveRefusal.NotMovable, + AgentChatMoveRefusal.DestinationNotFound, + AgentChatMoveRefusal.InvalidAnchor, + AgentChatMoveRefusal.DifferentProvider, + AgentChatMoveRefusal.SessionArchived, + AgentChatMoveRefusal.SessionNotReady, + AgentChatMoveRefusal.SessionQuarantined, + AgentChatMoveRefusal.ConversionInProgress, + AgentChatMoveRefusal.WorktreeFolder, + AgentChatMoveRefusal.Workspaceless, + AgentChatMoveRefusal.AgentMergeOwner, + AgentChatMoveRefusal.DestinationSingleFolder, + AgentChatMoveRefusal.LegacyCatalog, + AgentChatMoveRefusal.Conflict, + AgentChatMoveRefusal.Unsupported, + AgentChatMoveRefusal.NotSingleChat, + AgentChatMoveRefusal.NotStarted, + AgentChatMoveRefusal.DifferentCredentialOwner, +]); + +/** + * The reason a host refused a chat or session move: an {@link AgentChatMoveRefusedError}, or a protocol error whose + * `data.reason` names an {@link AgentChatMoveRefusal}. `undefined` for any other failure. + */ +export function getChatMoveRefusal(error: unknown): AgentChatMoveRefusal | undefined { + if (error instanceof AgentChatMoveRefusedError) { + return error.reason; + } + if (error instanceof ProtocolError && typeof error.data === 'object' && error.data !== null) { + const reason = (error.data as { readonly reason?: unknown }).reason; + if (typeof reason === 'string' && refusals.has(reason)) { + return reason as AgentChatMoveRefusal; + } + } + return undefined; +} + +/** A user-facing explanation of why a host refused a move. */ +export function getChatMoveRefusalMessage(reason: AgentChatMoveRefusal): string { + switch (reason) { + case AgentChatMoveRefusal.Disabled: + return localize('chatMoveRefusal.disabled', "Moving chats is turned off."); + case AgentChatMoveRefusal.NotMovable: + return localize('chatMoveRefusal.notMovable', "This chat can't be moved."); + case AgentChatMoveRefusal.DestinationNotFound: + return localize('chatMoveRefusal.destinationNotFound', "The destination session is not available."); + case AgentChatMoveRefusal.InvalidAnchor: + return localize('chatMoveRefusal.invalidAnchor', "The chat can't be placed at that position."); + case AgentChatMoveRefusal.DifferentProvider: + return localize('chatMoveRefusal.differentProvider', "Chats can only move between sessions of the same agent."); + case AgentChatMoveRefusal.SessionArchived: + return localize('chatMoveRefusal.sessionArchived', "Chats can't be moved into or out of an archived session."); + case AgentChatMoveRefusal.SessionNotReady: + return localize('chatMoveRefusal.sessionNotReady', "The session isn't ready yet. Try again in a moment."); + case AgentChatMoveRefusal.SessionQuarantined: + return localize('chatMoveRefusal.sessionQuarantined', "The session is changing its workspace or can't run requests right now."); + case AgentChatMoveRefusal.ConversionInProgress: + return localize('chatMoveRefusal.conversionInProgress', "The chat is changing its workspace. Try again when it finishes."); + case AgentChatMoveRefusal.WorktreeFolder: + return localize('chatMoveRefusal.worktreeFolder', "Chats that work in a worktree of their session can't be moved."); + case AgentChatMoveRefusal.Workspaceless: + return localize('chatMoveRefusal.workspaceless', "Chats without a workspace folder can't be moved."); + case AgentChatMoveRefusal.AgentMergeOwner: + return localize('chatMoveRefusal.agentMergeOwner', "This chat runs Agent Merge for the session and can't be moved."); + case AgentChatMoveRefusal.DestinationSingleFolder: + return localize('chatMoveRefusal.destinationSingleFolder', "The destination session can't take another folder."); + case AgentChatMoveRefusal.LegacyCatalog: + return localize('chatMoveRefusal.legacyCatalog', "This session can't reorder or move its chats yet."); + case AgentChatMoveRefusal.Conflict: + return localize('chatMoveRefusal.conflict', "The chats changed while moving. Try again."); + case AgentChatMoveRefusal.Unsupported: + return localize('chatMoveRefusal.unsupported', "This chat can't be moved there."); + case AgentChatMoveRefusal.NotSingleChat: + return localize('chatMoveRefusal.notSingleChat', "Only a session with a single chat can be moved into another session."); + case AgentChatMoveRefusal.NotStarted: + return localize('chatMoveRefusal.notStarted', "Send a message in this chat before moving it to its own session."); + case AgentChatMoveRefusal.DifferentCredentialOwner: + return localize('chatMoveRefusal.differentCredentialOwner', "This chat uses a different account's credentials than the destination session."); + } +} + +/** Replaces a host refusal by a {@link SessionMoveRefusedError} explaining it; other errors are returned unchanged. */ +export function toChatMoveError(error: unknown): unknown { + const reason = getChatMoveRefusal(error); + return reason === undefined ? error : new SessionMoveRefusedError(getChatMoveRefusalMessage(reason)); +} diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 23bde4a352b242..a0d6fc420b6152 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -28,7 +28,7 @@ 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'; import { readAgentSdkSetupInfos } from '../../../../../platform/agentHost/common/agentSdkSetup.js'; -import { type IAgentCanvasInfo, IAgentConnection } from '../../../../../platform/agentHost/common/agentService.js'; +import { AgentHostPrewarmSessionsSettingId, type IAgentCanvasInfo, IAgentConnection } from '../../../../../platform/agentHost/common/agentService.js'; import { fromAgentHostUri, type AgentHostUriMapper } from '../../../../../platform/agentHost/common/agentHostUri.js'; import type { RemoteAgentHostConnectionStatus } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { AgentHostTransportFailureReason } from '../../../../../platform/agentHost/common/state/sessionTransport.js'; @@ -96,8 +96,10 @@ import { dedupeLinks, partitionSessionArtifacts, type IRecordedGitHubReference } import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import type { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsRecentWorkspacesService } from '../../../../services/sessions/browser/sessionsRecentWorkspacesService.js'; -import { IAutomationSessionConfiguration, IChatMovedEvent, IDeleteChatOptions, type IForkChatOptions, ISendRequestOptions, ISessionChangeEvent, ISessionChangesetOperationInvokedEvent, ISessionConfigurationSnapshot, ISessionModelPickerOptions, ISessionModelsSnapshot, ISessionPermissionOption, ISessionsProviderCreateSessionOptions, ISessionWorktreeConfiguration } from '../../../../services/sessions/common/sessionsProvider.js'; +import { IAutomationSessionConfiguration, IChatMovedEvent, ISessionChatMoveDestination, SessionMoveRefusedError, IDeleteChatOptions, type IForkChatOptions, ISendRequestOptions, ISessionChangeEvent, ISessionChangesetOperationInvokedEvent, ISessionConfigurationSnapshot, ISessionModelPickerOptions, ISessionModelsSnapshot, ISessionPermissionOption, ISessionsProviderCreateSessionOptions, ISessionWorktreeConfiguration } from '../../../../services/sessions/common/sessionsProvider.js'; import { AgentHostChatMoveTracker } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatMoveTracker.js'; +import type { AgentChatMoveDestination } from '../../../../../platform/agentHost/common/agentHostChatMove.js'; +import { toChatMoveError } from './agentHostChatMoveErrors.js'; import { IGitHubService } from '../../../github/browser/githubService.js'; import { computePullRequestRefPresentation } from '../../../github/browser/pullRequestIconStatus.js'; import { IPullRequestIconCache } from '../../../github/browser/pullRequestIconCache.js'; @@ -1083,11 +1085,18 @@ class AdditionalChat extends Disposable { private readonly _isRead: ISettableObservable; private readonly _origin: ChatOrigin | undefined; private readonly _changesSummary: ISettableObservable; + private _movable: boolean; + + /** Whether the host advertises this chat as a move source (`ChatSummary.movable`). */ + get movable(): boolean { + return this._movable; + } constructor(resource: URI, summary: AgentHostChatSummary, createdAtFallback: Date, changesets: IObservable, backgroundShells: IObservable, private readonly _acquireDetails: () => IDisposable, sessionWorkspace: IObservable, mapWorkingDirectoryUri: AgentHostUriMapper, isNew: boolean = false, parentChat?: URI, sessionIsArchived: IObservable = constObservable(false), canArchive: IObservable = constObservable(false), output?: IChatOutputObs, sessionIsReadOnly: IObservable = constObservable(false), connectionStatus?: IObservable, allowOfflineDrafts: IObservable = constObservable(false)) { super(); this.backendUri = URI.parse(summary.resource); this._origin = summary.origin; + this._movable = summary.movable === true; const modifiedAt = summary.modifiedAt ? new Date(summary.modifiedAt) : undefined; this._title = observableValue('chatTitle', summary.title || localize('newChatTab', "New Chat")); this._status = observableValue('chatStatus', mapProtocolStatus(summary.status)); @@ -1169,6 +1178,7 @@ class AdditionalChat extends Disposable { update(summary: ChatSummary, supportsChatReadState: boolean = true): void { const modifiedAt = new Date(summary.modifiedAt); + this._movable = summary.movable === true; transaction(tx => { this._title.set(summary.title || localize('newChatTab', "New Chat"), tx); this._status.set(mapProtocolStatus(summary.status), tx); @@ -2226,6 +2236,14 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { return chatResource.fragment ? this._additionalChats.get(chatResource.fragment)?.backendUri : undefined; } + /** Whether the host advertises the chat as a move source; a session's default chat never is. */ + isChatMovable(chatResource: URI): boolean { + if (isEqual(chatResource, this._defaultChat.resource) || !chatResource.fragment) { + return false; + } + return this._additionalChats.get(chatResource.fragment)?.movable === true; + } + /** Whether `chat` is one of the backend chats this session's catalog lists. */ ownsBackendChat(chat: string): boolean { const defaultChat = this._defaultChatBackendUri.get()?.toString(); @@ -3448,6 +3466,42 @@ class NewSession extends Disposable { } getConfigValues(): Record | undefined { return this._config && filterSessionConfigValues(this._config.schema, this._config.values); } + /** Only pre-warming benefits from a folder draft, so other drafts keep the picked isolation. */ + private _createsInPickedFolder(): boolean { + return !!this.workspaceUri && this._configurationService.getValue(AgentHostPrewarmSessionsSettingId) === true; + } + + private _getIsolationProperty() { + return this._config && getSessionWorkspaceProperties(this._config.schema).isolation; + } + + /** + * The config the backend draft is created with. With session pre-warming + * on, isolation stays local until Send: the draft is created in the picked + * folder even when the user picked + * (or inherited) a worktree, so the host can pre-warm it, and the first + * request sends the picked isolation with the rest of the draft's config + * (`AgentHostSessionHandler`). The host creates a picked worktree on that + * first send and discards the folder warm-up. The worktree base branch is + * dropped with the isolation, so the folder draft uses its checked-out + * branch until Send. + */ + private _getEagerCreateConfigValues(): Record | undefined { + const values = this.getConfigValues(); + const config = this._config; + if (!values || !config || !this._createsInPickedFolder()) { + return values; + } + const workspace = getSessionWorkspaceProperties(config.schema); + const folder = writeSessionIsolation(workspace.isolation, 'folder'); + if (!workspace.isolation || folder === undefined || values[workspace.isolation.key] === folder) { + return values; + } + const eagerValues = { ...values, [workspace.isolation.key]: folder }; + delete eagerValues[workspace.baseBranchKey]; + return eagerValues; + } + trackConfigResolution(promise: Promise): Promise { this._configResolution = promise; void promise.then( @@ -3700,6 +3754,9 @@ class NewSession extends Disposable { * Failures are non-fatal: the legacy first-message path in * `AgentHostSessionHandler._invokeAgent` re-issues `createSession` if * no session state exists at send time. + * + * The session is created in the picked folder whatever isolation the + * draft shows; see {@link _getEagerCreateConfigValues}. */ eagerCreate(connection: IAgentConnection, canCreate: (() => Promise) | undefined, isAllowed: () => boolean): void { const backendUri = this.backendUri; @@ -3732,6 +3789,11 @@ class NewSession extends Disposable { await this.waitForConfigurationReady(); } await this._activeClientScope.whenResolved(); + // The folder isolation is written under the key the host's schema + // advertises, so wait for a schema when none is cached yet. + if (this._createsInPickedFolder() && !this._hasResolvedConfig && !this._getIsolationProperty()) { + await this.waitForConfigResolution(); + } if (this.cancellationToken.isCancellationRequested || this._eagerCreateSuspended || !isAllowed()) { return; } @@ -3744,7 +3806,7 @@ class NewSession extends Disposable { session: backendUri, ...(this._selectedModelId ? { model: this.getSelectedModel() } : {}), workingDirectories: this.workspaceUri ? [this.workspaceUri] : undefined, - config: this.getConfigValues(), + config: this._getEagerCreateConfigValues(), _meta: this._initialMetadata, // MCP-style opt-in: offer to receive `progress` for any // long-running bring-up (chiefly the lazy first-use SDK @@ -6500,7 +6562,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement return this._getModelsSnapshotForTarget(resourceScheme, desiredModelId); } - getModelsSnapshotForCreation(_workspaceUri: URI, sessionTypeId: string, desiredModelId?: string): ISessionModelsSnapshot { + getModelsSnapshotForCreation(_workspaceUri: URI | undefined, sessionTypeId: string, desiredModelId?: string): ISessionModelsSnapshot { return this._getModelsSnapshotForTarget(this.resourceSchemeForProvider(sessionTypeId), desiredModelId); } @@ -6836,14 +6898,53 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement const destination = this._sessionCache.get(this._sessionKeyFromChatId(destinationSessionId) ?? ''); const connection = this.connection; if (!source || !destination || !connection || !this.canMoveSessionUnder(sessionId)) { - throw new Error(localize('moveSessionUnavailable', "This session can't be moved.")); + throw new SessionMoveRefusedError(localize('moveSessionUnavailable', "This session can't be moved.")); } const backendAfter = after === undefined ? undefined : destination.getBackendChatResource(after); if (after !== undefined && !backendAfter) { - throw new Error(localize('moveSessionAnchorNotFound', "The chat to place the session after could not be found.")); + throw new SessionMoveRefusedError(localize('moveSessionAnchorNotFound', "The chat to place the session after could not be found.")); } // The chat's views follow it to the destination once both catalogs reflect the move (onDidMoveChat). - await connection.moveSession(source.backendUri, destination.backendUri, backendAfter); + try { + await connection.moveSession(source.backendUri, destination.backendUri, backendAfter); + } catch (error) { + throw toChatMoveError(error); + } + } + + canMoveChat(sessionId: string, chatUri: URI): boolean { + const cached = this._sessionCache.get(this._sessionKeyFromChatId(sessionId) ?? ''); + return !!cached && !!this.connection && cached.isChatMovable(chatUri); + } + + async moveChat(sessionId: string, chatUri: URI, destination: ISessionChatMoveDestination): Promise { + this._assertSessionAvailable(sessionId); + const source = this._sessionCache.get(this._sessionKeyFromChatId(sessionId) ?? ''); + const connection = this.connection; + const backendChat = source?.getBackendChatResource(chatUri); + if (!source || !backendChat || !connection || !source.isChatMovable(chatUri)) { + throw new SessionMoveRefusedError(localize('moveChatUnavailable', "This chat can't be moved.")); + } + let backendDestination: AgentChatMoveDestination; + if (destination.kind === 'session') { + const target = this._sessionCache.get(this._sessionKeyFromChatId(destination.sessionId) ?? ''); + if (!target) { + throw new SessionMoveRefusedError(localize('moveChatDestinationNotFound', "The destination session is not available.")); + } + const backendAfter = destination.after === undefined ? undefined : target.getBackendChatResource(destination.after); + if (destination.after !== undefined && !backendAfter) { + throw new SessionMoveRefusedError(localize('moveChatAnchorNotFound', "The chat to place the moved chat after could not be found.")); + } + backendDestination = { kind: 'session', session: target.backendUri, after: backendAfter }; + } else { + backendDestination = { kind: 'newSession' }; + } + // The chat's views follow it to its new session once both catalogs reflect the move (onDidMoveChat). + try { + await connection.moveChat(backendChat, backendDestination); + } catch (error) { + throw toChatMoveError(error); + } } async listCanvases(sessionId: string, chatResource: URI): Promise { @@ -8340,8 +8441,15 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (!cached) { return; } + const previousChats = cached.chats.get(); cached.applyChatCatalog(state); this._cacheDirty = true; + // Session lists read `chats` when sessions change. Added and removed chats reach them with the root summary + // change, but a reorder of a subscribed session's chats leaves the root summary as it was. + const chats = cached.chats.get(); + if (!arrayEquals(previousChats, chats) && previousChats.length === chats.length && chats.every(chat => previousChats.includes(chat))) { + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); + } } /** diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostChatMoveErrors.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostChatMoveErrors.test.ts new file mode 100644 index 00000000000000..476d5dc29d073a --- /dev/null +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostChatMoveErrors.test.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. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { AgentChatMoveRefusal, AgentChatMoveRefusedError } from '../../../../../../platform/agentHost/common/agentHostChatMove.js'; +import { JsonRpcErrorCodes, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; +import { getChatMoveRefusal, toChatMoveError } from '../../browser/agentHostChatMoveErrors.js'; +import { SessionMoveRefusedError } from '../../../../../services/sessions/common/sessionsProvider.js'; + +suite('Agent Host Chat Move Errors', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('reads the refusal reason of protocol and host errors and explains it without the host details', () => { + const archived = new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'Session agent-host://x is archived.', { reason: AgentChatMoveRefusal.SessionArchived }); + const notStarted = new AgentChatMoveRefusedError(AgentChatMoveRefusal.NotStarted, 'Chat x has no turns yet.'); + const credentials = new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'Sessions with different credential owners can\'t be combined.', { reason: AgentChatMoveRefusal.DifferentCredentialOwner }); + const unknownReason = new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'Bad params', { reason: 'somethingElse' }); + const failure = new Error('Connection closed'); + const message = (error: unknown) => error instanceof Error ? error.message : String(error); + + assert.deepStrictEqual({ + reasons: [archived, notStarted, credentials, unknownReason, failure].map(getChatMoveRefusal), + messages: [archived, notStarted, credentials, unknownReason, failure].map(error => message(toChatMoveError(error))), + // Only refusals become errors the sessions list shows as they are. + refused: [archived, notStarted, credentials, unknownReason, failure].map(error => toChatMoveError(error) instanceof SessionMoveRefusedError), + }, { + reasons: [AgentChatMoveRefusal.SessionArchived, AgentChatMoveRefusal.NotStarted, AgentChatMoveRefusal.DifferentCredentialOwner, undefined, undefined], + messages: [ + 'Chats can\'t be moved into or out of an archived session.', + 'Send a message in this chat before moving it to its own session.', + 'This chat uses a different account\'s credentials than the destination session.', + // Anything that is not a refusal passes through unchanged. + 'Bad params', + 'Connection closed', + ], + refused: [true, true, true, false, false], + }); + }); +}); 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 76132a141b212a..271aae26ce586d 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 @@ -27,7 +27,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/ import { AgentSession, CODEX_AGENT_PROVIDER_ID, type IAgentCanvasCloseRequest, type IAgentCanvasInfo, type IAgentCanvasOpenRequest, 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 { AgentHostCodexAgentEnabledSettingId, AgentHostPrewarmSessionsSettingId, IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; import { getAgentHostExtensionInitializeResultMeta } from '../../../../../../platform/agentHost/common/agentHostExtensionProtocol.js'; import { AgentHostAutonomousAutomationsCapabilityMetaKey } from '../../../../../../platform/agentHost/common/meta/agentHostAutomationsMeta.js'; import { CODEX_ACCOUNT_META_KEY } from '../../../../../../platform/agentHost/common/codexAccount.js'; @@ -40,7 +40,8 @@ import { AutomationRunOriginKind, AutomationRunStatus, ChatInteractivity as Prot import { AUTOMATION_CATALOG_URI, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, ChangesetStatus, isAhpAutomationCatalogChannel, ResponsePartKind, SessionSourceControlOutcome, SessionStatus as ProtocolSessionStatus, StateComponents, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, withMostRecentRelatedSessionPullRequest, withSessionCreationReference, withSessionExternal, withSessionEhcliAdoptable, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionWorkspaceless, withWorkingDirectoryKey, withWorkingDirectoryScopeId, type ChangesetState, type ChatState, type ChatSummary, type ComponentToState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { SessionArtifactType, withSessionArtifacts } from '../../../../../../platform/agentHost/common/sessionArtifacts.js'; import { ActionType, NotificationType, type ActionEnvelope, type IRootConfigChangedAction, type ChatAction, type SessionAction, type TerminalAction, type INotification, type ClientAnnotationsAction, type SessionSummaryChangedParams } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; -import { AHP_SESSION_NOT_FOUND, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; +import { AHP_SESSION_NOT_FOUND, JsonRpcErrorCodes, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; +import type { AgentChatMoveDestination } from '../../../../../../platform/agentHost/common/agentHostChatMove.js'; import { PROTOCOL_VERSION } from '../../../../../../platform/agentHost/common/state/protocol/version/registry.js'; import { SessionConfigKey } from '../../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { toRemoteSessionMessageMetadata, withRemoteSessionOrigin } from '../../../../../../platform/agentHost/common/meta/agentRemoteSessionMeta.js'; @@ -190,6 +191,15 @@ class MockAgentHostService extends mock() { this.movedSessions.push({ session: session.toString(), destination: destination.toString(), after: after?.toString() }); return destination; } + public movedChats: { chat: string; destination: string }[] = []; + public moveChatError: Error | undefined; + override async moveChat(chat: URI, destination: AgentChatMoveDestination): Promise { + if (this.moveChatError) { + throw this.moveChatError; + } + this.movedChats.push({ chat: chat.toString(), destination: destination.kind === 'session' ? `${destination.session.toString()} after ${destination.after?.toString()}` : 'newSession' }); + return destination.kind === 'session' ? destination.session : URI.parse('copilotcli:/new'); + } override async removeSessionArtifact(session: URI, artifactId: string): Promise { this.removedArtifacts.push({ session, artifactId }); } @@ -8086,6 +8096,48 @@ suite('LocalAgentHostSessionsProvider', () => { }); }); + for (const [prewarm, sendIsolation] of [[true, 'worktree'], [true, 'folder'], [false, 'worktree']] as const) { + test(`a worktree draft is created in the picked folder only when sessions pre-warm, and Send picks the isolation (prewarm=${prewarm}, send=${sendIsolation})`, async () => { + const configurationService = new TestConfigurationService(); + await configurationService.setUserConfiguration(AgentHostPrewarmSessionsSettingId, prewarm); + agentHost.resolveSessionConfigResult = { + schema: createVSCodeSessionConfigSchema(), + values: { isolation: 'worktree', branch: 'origin/main' }, + }; + let sentConfig: Record | undefined; + const provider = createProvider(disposables, agentHost, undefined, { + configurationService, + openSession: true, + sendRequest: async (resource, _message, options): Promise => { + sentConfig = options?.agentHostSessionConfig; + agentHost.addSession(createSession(AgentSession.id(resource), { summary: 'Created From Send' })); + return { kind: 'sent' as const, data: {} as ChatSendResult extends { kind: 'sent'; data: infer D } ? D : never }; + }, + }); + const session = provider.createNewSession(URI.file('/home/user/project'), provider.sessionTypes[0].id); + await waitForSessionConfig(provider, session, config => config?.values.isolation === 'worktree'); + if (sendIsolation === 'folder') { + agentHost.resolveSessionConfigResult = { + schema: createVSCodeSessionConfigSchema(), + values: { isolation: 'folder', branch: 'feature' }, + }; + await provider.setSessionConfigValue(session.sessionId, session.mainChat.get().resource, SessionConfigKey.Isolation, 'folder'); + } + const chat = await provider.createNewChat(session.sessionId); + await provider.sendRequest(session.sessionId, chat.resource, { query: 'hello' }); + + assert.deepStrictEqual({ + created: agentHost.createSessionConfigs.map(created => created.config), + sent: { isolation: sentConfig?.[SessionConfigKey.Isolation], branch: sentConfig?.[SessionConfigKey.Branch] }, + }, { + created: [prewarm ? { isolation: 'folder' } : { isolation: 'worktree', branch: 'origin/main' }], + sent: sendIsolation === 'worktree' + ? { isolation: 'worktree', branch: 'origin/main' } + : { isolation: 'folder', branch: 'feature' }, + }); + }); + } + test('Automation drafts merge derived worktree settings with saved provider configuration after discovery', async () => { const configService = new TestConfigurationService(); configService.setUserConfiguration('git.branchPrefix', 'automation/'); @@ -11469,6 +11521,68 @@ suite('LocalAgentHostSessionsProvider', () => { }); }); + test('tells session lists when a subscribed session reorders its chats, also when its root summary stays the same (Block 5)', async () => { + const provider = createProvider(disposables, agentHost); + const session = setupMultiChatSession(provider, 'reorder-notify'); + const sessionUri = AgentSession.uri('copilotcli', 'reorder-notify').toString(); + const defaultChat = buildDefaultChatUri(sessionUri); + // Former default chats of other sessions, as after moving sessions under this one. + const [first, second] = ['first', 'second'].map(id => buildDefaultChatUri(AgentSession.uri('copilotcli', id).toString())); + const peer = (resource: string, title: string): ChatSummary => ({ ...makeChatSummary(resource, title), origin: { kind: ProtocolChatOriginKind.User }, movable: true }); + agentHost.setSessionState('reorder-notify', 'copilotcli', makeState([makeChatSummary(defaultChat, ''), peer(first, 'First'), peer(second, 'Second')], { defaultChat })); + const changed: string[][] = []; + disposables.add(provider.onDidChangeSessions(e => changed.push(e.changed.filter(candidate => candidate.sessionId === session.sessionId).map(candidate => candidate.chats.get().map(chat => chat.title.get()).join(','))))); + + agentHost.setSessionState('reorder-notify', 'copilotcli', makeState([makeChatSummary(defaultChat, ''), peer(second, 'Second'), peer(first, 'First')], { defaultChat })); + // An added chat reaches session lists with the root summary change, not again from the catalog. + const third = buildDefaultChatUri(AgentSession.uri('copilotcli', 'third').toString()); + agentHost.setSessionState('reorder-notify', 'copilotcli', makeState([makeChatSummary(defaultChat, ''), peer(second, 'Second'), peer(first, 'First'), peer(third, 'Third')], { defaultChat })); + + assert.deepStrictEqual(changed.filter(entry => entry.length > 0), [['Session,Second,First']]); + }); + + test('moves only movable peer chats, to the host resources of the destination, explaining refusals (Block 5)', async () => { + const provider = createProvider(disposables, agentHost); + const source = setupMultiChatSession(provider, 'move-chat-source'); + const target = setupMultiChatSession(provider, 'move-chat-target'); + const sourceUri = AgentSession.uri('copilotcli', 'move-chat-source').toString(); + const sourceDefault = buildDefaultChatUri(sourceUri); + const movablePeer = buildChatUri(sourceUri, 'movable'); + const fixedPeer = buildChatUri(sourceUri, 'fixed'); + agentHost.setSessionState('move-chat-source', 'copilotcli', makeState([ + makeChatSummary(sourceDefault, ''), + { ...makeChatSummary(movablePeer, 'Movable'), origin: { kind: ProtocolChatOriginKind.User }, movable: true }, + { ...makeChatSummary(fixedPeer, 'Fixed'), origin: { kind: ProtocolChatOriginKind.User } }, + ], { defaultChat: sourceDefault })); + const targetUri = AgentSession.uri('copilotcli', 'move-chat-target').toString(); + const targetDefault = buildDefaultChatUri(targetUri); + const targetPeer = buildChatUri(targetUri, 'peer'); + agentHost.setSessionState('move-chat-target', 'copilotcli', makeState([ + makeChatSummary(targetDefault, ''), + { ...makeChatSummary(targetPeer, 'Target Peer'), origin: { kind: ProtocolChatOriginKind.User } }, + ], { defaultChat: targetDefault })); + const [main, movable, fixed] = source.chats.get(); + const [, targetPeerChat] = target.chats.get(); + const canMove = [main, movable, fixed].map(chat => provider.canMoveChat(source.sessionId, chat.resource)); + + await provider.moveChat(source.sessionId, movable.resource, { kind: 'session', sessionId: target.sessionId, after: targetPeerChat.resource }); + await provider.moveChat(source.sessionId, movable.resource, { kind: 'session', sessionId: source.sessionId, after: undefined }); + await provider.moveChat(source.sessionId, movable.resource, { kind: 'newSession' }); + await assert.rejects(() => provider.moveChat(source.sessionId, fixed.resource, { kind: 'newSession' }), /This chat can't be moved\./); + agentHost.moveChatError = new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Session ${targetUri} is archived.`, { reason: 'sessionArchived' }); + await assert.rejects(() => provider.moveChat(source.sessionId, movable.resource, { kind: 'session', sessionId: target.sessionId, after: undefined }), /^Error: Chats can't be moved into or out of an archived session\.$/); + + assert.deepStrictEqual({ canMove, moved: agentHost.movedChats }, { + // Only the chat the host advertises as movable; never the main chat. + canMove: [false, true, false], + moved: [ + { chat: movablePeer, destination: `${targetUri} after ${targetPeer}` }, + { chat: movablePeer, destination: `${sourceUri} after undefined` }, + { chat: movablePeer, destination: 'newSession' }, + ], + }); + }); + test('peer chat archive dispatches to the host-supplied chat resource', async () => { const provider = createProvider(disposables, agentHost); const session = setupMultiChatSession(provider, 'chat-archive-resource'); diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts index a0652d3369a6e6..f81e2687f6159a 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -33,7 +33,7 @@ import { linkKey } from '../../../../common/sessionLinks.js'; import { getSessionApplication } from '../../../../common/sessionApplication.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel, isChatPermissionLevel } from '../../../../../workbench/contrib/chat/common/constants.js'; import { basename, dirname, isEqual } from '../../../../../base/common/resources.js'; -import { IAutomationSessionConfiguration, IChatMovedEvent, IDeleteChatOptions, ISendRequestOptions, ISessionChangeEvent, ISessionConfigProvider, ISessionConfigurationSnapshot, ISessionModelPickerOptions, ISessionModelsSnapshot, ISessionsProvider, ISessionsProviderCreateSessionOptions } from '../../../../services/sessions/common/sessionsProvider.js'; +import { IAutomationSessionConfiguration, IChatMovedEvent, IDeleteChatOptions, ISessionChatMoveDestination, SessionMoveRefusedError, ISendRequestOptions, ISessionChangeEvent, ISessionConfigProvider, ISessionConfigurationSnapshot, ISessionModelPickerOptions, ISessionModelsSnapshot, ISessionsProvider, ISessionsProviderCreateSessionOptions } from '../../../../services/sessions/common/sessionsProvider.js'; import { ISessionOptionGroup } from '../../../chat/browser/newSession.js'; import { UNIFIED_WORKSPACE_PICKER_SETTING } from '../../../chat/common/constants.js'; import { CancellationToken, CancellationTokenSource, cancelOnDispose } from '../../../../../base/common/cancellation.js'; @@ -1595,7 +1595,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions }; } - getModelsSnapshotForCreation(_workspaceUri: URI, sessionTypeId: string, desiredModelId?: string): ISessionModelsSnapshot { + getModelsSnapshotForCreation(_workspaceUri: URI | undefined, sessionTypeId: string, desiredModelId?: string): ISessionModelsSnapshot { if (this.providerMode !== 'sandbox' && sessionTypeId !== CopilotCloudSessionType.id) { return { models: [], desiredModelResolution: resolveModelIdentifier([], desiredModelId, true), modelTarget: undefined }; } @@ -1838,6 +1838,22 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions throw new Error('Deleting individual chats is not supported for Copilot Cloud sessions'); } + canMoveChat(_sessionId: string, _chatUri: URI): boolean { + return false; + } + + async moveChat(_sessionId: string, _chatUri: URI, _destination: ISessionChatMoveDestination): Promise { + throw new SessionMoveRefusedError(localize('copilotChatSessions.moveChatUnsupported', "Chats of this session can't be moved.")); + } + + canMoveSessionUnder(_sessionId: string): boolean { + return false; + } + + async moveSessionUnder(_sessionId: string, _destinationSessionId: string, _after: URI | undefined): Promise { + throw new SessionMoveRefusedError(localize('copilotChatSessions.moveSessionUnsupported', "This session can't be moved into another session.")); + } + async forkChat(sessionId: string, _sourceChat: URI, _turnId: string): Promise { throw new Error(`Session '${sessionId}' does not support forking into a chat`); } 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 eb123e44c80839..7f4b3bb39979f9 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 @@ -20,6 +20,7 @@ import { type ProtectedResourceMetadata } from '../../../../../../platform/agent import { type AgentInfo, type RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { type IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { IDefaultAccountService } from '../../../../../../platform/defaultAccount/common/defaultAccount.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { getSingletonServiceDescriptors } from '../../../../../../platform/instantiation/common/extensions.js'; @@ -72,6 +73,9 @@ interface IRemoteAuthenticationHarness extends IRemoteAuthNotificationHarness { function createAuthenticationInstantiationService(store: Pick): TestInstantiationService { const service = store.add(new TestInstantiationService()); service.stub(IConfigurationService, new TestConfigurationService()); + service.stub(IDefaultAccountService, new class extends mock() { + override readonly currentDefaultAccount = null; + }()); return service; } diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css index c147d7e0095832..3af024bbe71f1b 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css @@ -1407,6 +1407,26 @@ display: block; } +/* Feedback while moving a chat or session: a clear insertion line between rows (also when placing a session between sessions of Pinned or a custom group), and an outlined list for a new session. Moving into a session highlights its rows with the list's drop background, as in the Explorer. */ +.sessions-list-control.move-drag .monaco-list .monaco-list-rows .monaco-list-row.drop-target-before::before, +.sessions-list-control.move-drag .monaco-list .monaco-list-rows .monaco-list-row.drop-target-after::after { + height: calc(2 * var(--vscode-strokeThickness)); + background-color: var(--vscode-focusBorder); + z-index: 1; +} + +/* Among a session's nested rows, the line starts at their indent, unlike a line placing a session between sessions. */ +.sessions-list-control.move-drag.move-drag-nested .monaco-list .monaco-list-rows .monaco-list-row.drop-target-before::before, +.sessions-list-control.move-drag.move-drag-nested .monaco-list .monaco-list-rows .monaco-list-row.drop-target-after::after { + left: var(--vscode-spacing-size360); + width: calc(100% - var(--vscode-spacing-size360)); +} + +.sessions-list-control.move-drag .monaco-list.drop-target { + box-shadow: inset 0 0 0 var(--vscode-strokeThickness) var(--vscode-focusBorder); + border-radius: var(--vscode-cornerRadius-medium); +} + /* Header-only feedback while dragging sessions into a group or Pinned. */ .sessions-list-control .monaco-list-row.drop-target:has(.session-section), .sessions-list-control .monaco-list-row:has(.session-header-drop-target) { diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts index f81d8fc43e19bb..4c8f6a937c1c81 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts @@ -66,13 +66,18 @@ import { logSessionsInteraction, SessionsInteractionSource } from '../../../comm import { NEW_SESSION_ACTION_ID } from '../../chat/common/constants.js'; import { groupSessionsForPicker } from './sessionsPicker.js'; import { getSessionConversationActionId, SESSION_CONVERSATION_SIDE_CHATS_GROUP } from '../../../browser/sessionConversationGroups.js'; -import { ISessionChatItem, SessionChatItemCanDeleteContext, SessionChatItemCanRenameContext, SessionChatItemIsUntitledContext, SessionItemInExternalSectionContext, SessionsList, SessionsListFocusedChatItemContext } from './views/sessionsList.js'; +import { ISessionChatItem, SessionChatItemCanDeleteContext, SessionChatItemCanMoveContext, SessionChatItemCanRenameContext, SessionChatItemIsUntitledContext, SessionItemInExternalSectionContext, SessionsList, SessionsListFocusedChatItemContext } from './views/sessionsList.js'; import { SessionsView, SessionsViewId } from './views/sessionsView.js'; import './media/newSessionActionViewItem.css'; import { INewSessionComposerService } from '../../chat/browser/newSessionComposerService.js'; import { SESSIONS_CHAT_TABS_SETTING, SessionsChatTabsMode } from '../../../common/sessionConfig.js'; import { Direction } from '../../../../base/browser/ui/grid/grid.js'; import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { ChatMoveSettingId } from '../../../../platform/agentHost/common/agentHostChatMove.js'; +import { IChatEntitlementService } from '../../../../workbench/services/chat/common/chatEntitlementService.js'; +import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { INotificationService } from '../../../../platform/notification/common/notification.js'; +import { isChatMoveDndEnabled, runSessionsListMove, SessionsListMove } from './views/sessionsListChatMove.js'; export const NEW_SESSION_BUTTON_STYLE_SETTING = 'sessions.newSessionButton.style'; export const NEW_SESSION_BUTTON_STYLE_TREATMENT = 'agentSessionsNewSessionButtonStyle'; @@ -741,6 +746,44 @@ registerAction2(class OpenSessionListChatToSideAction extends Action2 { } }); +/** Moves a nested session (a chat of a session) into a new session of its own, like dropping it on the list's empty space. */ +export const PROMOTE_CHAT_TO_SESSION_ACTION_ID = 'sessions.list.promoteChatToSession'; + +registerAction2(class PromoteChatToSessionAction extends Action2 { + constructor() { + super({ + id: PROMOTE_CHAT_TO_SESSION_ACTION_ID, + title: localize2('promoteChatToSession', "Promote to Session"), + f1: false, + menu: [{ + id: Menus.SessionChatItemContext, + // Its own group, between the chat's actions and Delete. + group: '1_move', + order: 1, + when: ContextKeyExpr.and(ContextKeyExpr.equals(`config.${ChatMoveSettingId.Enabled}`, true), ChatContextKeys.enabled, SessionChatItemCanMoveContext), + }], + }); + } + + override async run(accessor: ServicesAccessor, context?: ISessionChatItem): Promise { + const sessionsManagementService = accessor.get(ISessionsManagementService); + const configurationService = accessor.get(IConfigurationService); + if (!context || !isChatMoveDndEnabled(configurationService, accessor.get(IChatEntitlementService)) || !sessionsManagementService.canMoveChat(context.session, context.chat)) { + return false; + } + const move: SessionsListMove = { kind: 'chat', session: context.session, chat: context.chat, destination: { kind: 'newSession' } }; + // Like a drop on the list, reveal and select the new session in the list once it shows it. + const revealMoved = getSessionsList(accessor)?.watchMoveForReveal(move); + let moved = false; + try { + moved = await runSessionsListMove(move, sessionsManagementService, accessor.get(IDialogService), configurationService, accessor.get(INotificationService), accessor.get(ILogService)); + } finally { + revealMoved?.(moved); + } + return moved; + } +}); + registerAction2(class DeleteSessionListChatAction extends Action2 { constructor() { super({ diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index 735369bb807247..26ce7aad2d4eb4 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -8,15 +8,17 @@ import * as DOM from '../../../../../base/browser/dom.js'; import { renderAsPlaintext } from '../../../../../base/browser/markdownRenderer.js'; import { animateChatProgress } from '../../../../../workbench/contrib/chat/browser/widget/chatProgressAnimation.js'; import { Gesture } from '../../../../../base/browser/touch.js'; +import * as aria from '../../../../../base/browser/ui/aria/aria.js'; import { IListRenderer, IListVirtualDelegate, ListDragOverEffectPosition, ListDragOverEffectType, NotSelectableGroupId } from '../../../../../base/browser/ui/list/list.js'; import { IListStyles } from '../../../../../base/browser/ui/list/listWidget.js'; -import { IObjectTreeElement, ITreeNode, ITreeRenderer, ITreeContextMenuEvent, ObjectTreeElementCollapseState, ITreeDragAndDrop, ITreeDragOverReaction } from '../../../../../base/browser/ui/tree/tree.js'; +import { IObjectTreeElement, ITreeNode, ITreeRenderer, ITreeContextMenuEvent, ObjectTreeElementCollapseState, ITreeDragAndDrop, ITreeDragOverReaction, TreeDragOverBubble } from '../../../../../base/browser/ui/tree/tree.js'; import { LabelFuzzyScore, RenderIndentGuides, TreeFindMode } from '../../../../../base/browser/ui/tree/abstractTree.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { onUnexpectedError } from '../../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { HighlightedLabel } from '../../../../../base/browser/ui/highlightedlabel/highlightedLabel.js'; import { createMatches, FuzzyScore, IMatch } from '../../../../../base/common/filters.js'; +import { disposableTimeout } from '../../../../../base/common/async.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { constObservable, IObservable, IReader, ISettableObservable, autorun, derived, observableFromEvent, observableSignalFromEvent, observableValue } from '../../../../../base/common/observable.js'; @@ -36,7 +38,7 @@ import { ICommandService } from '../../../../../platform/commands/common/command import { ContextKeyValue, IContextKey, IContextKeyService, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; import { MarshalledId } from '../../../../../base/common/marshallingIds.js'; import { SessionProviderIdContext, SessionSupportsDeleteContext, SessionSupportsMultipleChatsContext, SessionSupportsRenameContext, SessionTypeContext, IsPhoneLayoutContext, IsQuickChatSessionContext, SessionIsArchivedContext, SessionIsReadContext, SessionHasPullRequestContext, SessionItemIsMultiSelectionContext } from '../../../../common/contextkeys.js'; -import { ARCHIVE_SESSION_COMMAND_ID, RENAME_CHAT_COMMAND_ID, RENAME_SESSION_COMMAND_ID } from '../../../../common/sessionCommands.js'; +import { ARCHIVE_SESSION_COMMAND_ID, ARCHIVE_SESSIONS_WITH_UNDO_COMMAND_ID, RENAME_CHAT_COMMAND_ID, RENAME_SESSION_COMMAND_ID } from '../../../../common/sessionCommands.js'; import { ExternalSessionApplicationBadgeMode, SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SETTING, SESSIONS_LIST_EXTERNAL_APPLICATION_BADGE_SHOW_FROM_SETTING, SESSIONS_LIST_GROUP_EXTERNAL_SESSIONS_SETTING } from '../../../../common/sessionConfig.js'; import { IContextMenuService, IContextViewService } from '../../../../../platform/contextview/browser/contextView.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; @@ -61,6 +63,8 @@ import { BRANCH_CHANGES_CHANGESET_ID, ChatInteractivity, ChatOriginKind, getChat import { ISessionChangesStats, readChatChangesStats } from '../../../../services/sessions/common/sessionChangesStatsCache.js'; import { AgentSessionApprovalModel, agentSessionApprovalId, IAgentSessionApprovalInfo } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; import { IVoicePlaybackService } from '../../../../../workbench/contrib/chat/common/voicePlaybackService.js'; +import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { IChatEntitlementService } from '../../../../../workbench/services/chat/common/chatEntitlementService.js'; import { Button } from '../../../../../base/browser/ui/button/button.js'; import { IMarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; import { Action, ActionRunner, IAction, Separator, SubmenuAction, toAction } from '../../../../../base/common/actions.js'; @@ -101,6 +105,10 @@ import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/ import { buildOpenSessionLinkUri } from '../../../../../platform/agentHost/common/openSessionLink.js'; import { LocalSelectionTransfer } from '../../../../../platform/dnd/browser/dnd.js'; import { DraggedSessionIdentifier, fillSessionChatDragData, SessionsDataTransfers } from '../../../../browser/dnd.js'; +import { getSessionsListChatDrop, getSessionsListSessionDrop, isChatMoveDndEnabled, ISessionsListChatDropTarget, runSessionsListMove, SessionsListChatDrop, SessionsListMove, SessionsListSessionDrop } from './sessionsListChatMove.js'; +import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { INotificationService } from '../../../../../platform/notification/common/notification.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; import { IDragAndDropData } from '../../../../../base/browser/dnd.js'; import { ElementsDragAndDropData, ListViewTargetSector } from '../../../../../base/browser/ui/list/listView.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; @@ -127,6 +135,8 @@ const NEW_SESSION_SECTION_ID = 'newSession'; const AUTOMATIONS_SECTION_ID = 'automations'; const CUSTOMIZATIONS_SECTION_ID = 'customizations'; const EXTERNAL_SESSIONS_SECTION_ID = 'external'; +/** Ungrouped sessions sectioned by date share one sort order across these sections, in display order. */ +const DATE_SECTION_IDS: readonly string[] = ['recent', 'older']; const SESSION_SHORTCUT_SECTION_TEMPLATE_ID = 'session-shortcut-section'; const SESSION_SHORTCUT_ROW_HEIGHT = 28; const SESSION_SECTION_FOCUS_FROM_POINTER_CLASS = 'session-section-focus-from-pointer'; @@ -151,8 +161,12 @@ type SessionContextKeyProvider = (session: ISession, reader: IReader | undefined export const SessionChatItemCanRenameContext = new RawContextKey('sessionChatItem.canRename', false); export const SessionChatItemCanArchiveContext = new RawContextKey('sessionChatItem.canArchive', false); export const SessionChatItemCanDeleteContext = new RawContextKey('sessionChatItem.canDelete', false); +/** Whether the chat item's chat can move out of its session, e.g. into a new session: its provider moves chats and the chat is movable. */ +export const SessionChatItemCanMoveContext = new RawContextKey('sessionChatItem.canMove', false); export const SessionChatItemIsArchivedContext = new RawContextKey('sessionChatItem.isArchived', false); export const SessionChatItemIsUntitledContext = new RawContextKey('sessionChatItem.isUntitled', false); +export const SessionChatItemCanMoveUpContext = new RawContextKey('sessionChatItem.canMoveUp', false); +export const SessionChatItemCanMoveDownContext = new RawContextKey('sessionChatItem.canMoveDown', false); export const SessionsListFocusedChatItemContext = new RawContextKey('sessionsList.focusedChatItem', false); /** Whether the focused session item currently belongs to a user group. */ export const SessionItemInGroupContext = new RawContextKey('sessionItem.inGroup', false); @@ -184,6 +198,9 @@ function sortingToMode(sorting: SessionsSorting): SessionSortMode { /** Fallback spacing (ms) used when assigning synthetic sort keys past an open boundary. */ const SORT_FALLBACK_STEP_MS = 60_000; +/** Spacing (ms) between synthetic sort keys that keep dropped sessions directly next to their anchor. */ +const ADJACENT_SORT_STEP_MS = 1; + export interface ISessionSection { readonly id: string; readonly collapseStateId?: string; @@ -3112,35 +3129,144 @@ class SessionsAccessibilityProvider { //#region Drag and Drop /** - * Callbacks the sessions list provides to its drag-and-drop controller so the - * controller can validate and apply manual reordering without owning the list - * model itself. + * Where dropped sessions end up. Each placement is a rung of the list's + * placement precedence (archived > pinned > custom group > ungrouped section), + * so moving sessions into one only changes the state that precedence reads. + */ +type SessionListPlacement = + | { readonly kind: 'archived' } + | { readonly kind: 'pinned' } + | { readonly kind: 'group'; readonly groupId: string } + /** The section a session renders in when it is neither archived, pinned, nor grouped. */ + | { readonly kind: 'section'; readonly sectionId: string }; + +/** The list-owned state a session needs to render in a placement. */ +interface ISessionPlacementState { + readonly archived: boolean; + /** The pin state, or `undefined` to leave it unchanged. */ + readonly pinned?: boolean; + /** The custom group, `null` for none, or `undefined` to leave membership unchanged. */ + readonly groupId?: string | null; +} + +function getPlacementState(placement: SessionListPlacement): ISessionPlacementState { + switch (placement.kind) { + case 'archived': + return { archived: true }; + case 'pinned': + return { archived: false, pinned: true }; + case 'group': + return { archived: false, pinned: false, groupId: placement.groupId }; + case 'section': + return { archived: false, pinned: false, groupId: null }; + } +} + +/** The placement of the sessions rendered under a section or group id, or `undefined` when it does not accept sessions. */ +function getSectionPlacement(sectionId: string): SessionListPlacement | undefined { + if (sectionId === 'pinned') { + return { kind: 'pinned' }; + } + if (sectionId === 'archived') { + return { kind: 'archived' }; + } + if (sectionId.startsWith('group:')) { + return { kind: 'group', groupId: sectionId.slice('group:'.length) }; + } + return isShortcutSection(sectionId) ? undefined : { kind: 'section', sectionId }; +} + +function getPlacementHeader(placement: SessionListPlacement): ISessionDropTargetHeader { + switch (placement.kind) { + case 'archived': + return { kind: 'section', id: 'archived' }; + case 'pinned': + return { kind: 'section', id: 'pinned' }; + case 'group': + return { kind: 'group', id: placement.groupId }; + case 'section': + return { kind: 'section', id: placement.sectionId }; + } +} + +/** A session row that dropped sessions are positioned against. */ +interface ISessionDropAnchor { + readonly session: ISession; + readonly position: 'before' | 'after'; + /** + * The anchor's section is derived from the sort key (date sections), so the + * sessions must stay next to the anchor rather than drift past a neighbour + * in another section. + */ + readonly staysInSection: boolean; +} + +/** Row feedback shown while hovering a drop target. */ +interface ISessionDropFeedback { + readonly position: ListDragOverEffectPosition; + /** The list index that shows the feedback, when it differs from the hovered row. */ + readonly index?: number; +} + +/** Where sessions dropped on a list target go. */ +interface ISessionDropDestination { + readonly placement: SessionListPlacement; + /** The row to position the sessions against; `undefined` keeps their natural sort position. */ + readonly anchor?: ISessionDropAnchor; + /** Whether the drop may restore archived sessions. */ + readonly restoresArchived: boolean; + /** Row feedback; `undefined` relies on the destination header highlight alone. */ + readonly feedback?: ISessionDropFeedback; +} + +/** What a valid session drop changes: the placement of at least one session, or only the manual order. */ +type SessionDropEffect = 'move' | 'reorder'; + +/** + * Callbacks the sessions list provides to its drag-and-drop controller. The + * controller decides where a drop goes; the list decides whether its sessions + * may go there and applies the change. */ interface ISessionsListDndDelegate { - /** Whether a session may participate in reordering within its current section. */ - isReorderable(session: ISession): boolean; - /** Whether a session currently renders in the Pinned section. */ - isSessionPinned(session: ISession): boolean; - /** Whether the dragged sessions may be reordered relative to the given target. */ - canDropOn(dragged: ISession[], target: ISession): boolean; + /** The placement of the section or group a rendered session row belongs to. */ + getRowPlacement(session: ISession): SessionListPlacement | undefined; + /** The visible nested chat rows of a rendered session, in display order. */ + getVisibleChatRows(session: ISession): readonly ISessionChatItem[]; + /** The rendered date sections in display order, or `undefined` when not grouping by date. */ + getDateSections(): readonly ISessionSection[] | undefined; + /** What moving the sessions to the destination changes, or `undefined` when the move is not allowed. */ + getSessionDropEffect(sessions: readonly ISession[], destination: ISessionDropDestination): SessionDropEffect | undefined; + /** Move the sessions to the destination. */ + moveSessions(sessions: readonly ISession[], destination: ISessionDropDestination): void; + /** Place the chats before/after a sibling chat of the same session. */ + reorderChats(chats: readonly ISessionChatItem[], target: ISessionChatItem, position: 'before' | 'after'): void; /** Whether a group or workspace header participates in top-level reordering. */ isHeaderReorderable(element: ISessionGroupItem | ISessionSection): boolean; - /** Apply the reorder, placing the dragged sessions before/after the target. */ - reorder(dragged: ISession[], target: ISession, position: 'before' | 'after'): void; - /** The id of the group the session belongs to, or `undefined`. */ - getGroupIdOfSession(session: ISession): string | undefined; - /** The workspace or External section a grouped session can return to, if available. */ - getUngroupedSectionIdOfSession(session: ISession): string | undefined; - /** Add the given sessions to the group. */ - addSessionsToGroup(sessions: ISession[], groupId: string, target: ISession | undefined, position: 'before' | 'after' | undefined): void; - /** Remove the given sessions from their group, optionally placing them before/after a target. */ - removeSessionsFromGroup(sessions: ISession[], target: ISession | undefined, position: 'before' | 'after' | undefined): void; - /** Pin the given sessions, optionally placing them before/after a pinned target. */ - pinSessions(sessions: ISession[], target: ISession | undefined, position: 'before' | 'after' | undefined): void; /** Highlight only the header that will receive the dragged sessions. */ setDropTargetHeader(header: ISessionDropTargetHeader | undefined): void; /** Reorder a top-level header (group or workspace section) before/after another. */ reorderSection(draggedId: string, targetId: string, position: 'before' | 'after', isWorkspace: boolean): void; + /** The dragged chat row's chat, with its session, when moving chats by drag and drop is on and it can move. */ + getMovableDraggedChat(item: ISessionChatItem): { readonly session: ISession; readonly chat: IChat } | undefined; + /** Whether the session can move under another session, when moving chats by drag and drop is on. */ + canMoveSessionUnder(session: ISession): boolean; + /** Move the chat of `session` as the drop says. */ + moveChat(session: ISession, chat: IChat, drop: SessionsListChatDrop): void; + /** Move `session` under `target`, after its chat `after` (`undefined`: first). */ + moveSessionUnder(session: ISession, target: ISession, after: IChat | undefined): void; + /** Whether the session is in a section the user orders by hand: Pinned or a custom group. */ + isInManuallyOrderedSection(session: ISession): boolean; + /** + * Whether the row is the last one its section shows (under a find filter, the last one that matches): its last + * session's row, or that session's last chat row. + */ + isLastRowOfSection(element: ISession | ISessionChatItem): boolean; + /** + * Shows the drop feedback for moving a chat or session rather than the list's other drags: `'move'` for a drop into + * a session, a new session, or a placement between sessions; `'nested'` for an insertion line among a session's + * nested rows, drawn at their indent; `'none'` for the list's other drags. + */ + setMoveDropFeedback(kind: 'none' | 'move' | 'nested'): void; } interface ISessionDropTargetHeader { @@ -3148,17 +3274,6 @@ interface ISessionDropTargetHeader { readonly id: string; } -interface ISessionMembershipDropTarget { - readonly sessions: ISession[]; - readonly header: ISessionDropTargetHeader; - readonly target: ISession | undefined; - readonly position: 'before' | 'after' | undefined; -} - -interface ISessionAddToGroupDropTarget extends ISessionMembershipDropTarget { - readonly groupId: string; -} - /** A top-level header (group or workspace section) currently being dragged to reorder. */ interface IDraggedHeader { /** The reorder identity (`group:` or `workspace: